Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 29 additions & 11 deletions __tests__/action.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,13 +137,27 @@ describe('Action', () => {
});
});

describe('nested metadata validation', () => {
describe('metadata validation', () => {
const metadata = {
name: 'Fixture action',
description: 'Fixture description',
runs: { using: 'node24', main: 'index.js' },
};

it.each(['node24', 42, true])('rejects scalar runs %j with metadata context', async (runs) => {
vi.mocked(fs.readFileSync).mockReturnValueOnce(YAML.stringify({ ...metadata, runs }));
const { default: Action } = await import('../src/Action.js');

expect(() => new Action(actTestYmlPath, mockLogTask)).toThrow(
expect.objectContaining({
message: expect.stringContaining(`Failed to load ${actTestYmlPath}`),
cause: expect.objectContaining({
message: `Invalid action metadata syntax in ${actTestYmlPath}.`,
}),
}),
);
});

it.each([
{ inputs: { option: null } },
{ inputs: { option: 42 } },
Expand All @@ -154,19 +168,23 @@ describe('Action', () => {
])('rejects malformed nested metadata through YAML loading: %j', async (nested) => {
vi.mocked(fs.readFileSync).mockReturnValueOnce(YAML.stringify({ ...metadata, ...nested }));
const { default: Action } = await import('../src/Action.js');
expect(() => new Action(actTestYmlPath, mockLogTask)).toThrow('Invalid action metadata syntax');
expect(() => new Action(actTestYmlPath, mockLogTask)).toThrow(
'Invalid action metadata syntax',
);
});

it('accepts scalar defaults and nullish descriptions through YAML loading', async () => {
vi.mocked(fs.readFileSync).mockReturnValueOnce(YAML.stringify({
...metadata,
inputs: {
enabled: { description: 'Enabled', default: false, required: false },
retries: { description: 'Retries', default: 3 },
optional: { default: null, description: null },
},
outputs: { result: { description: null } },
}));
vi.mocked(fs.readFileSync).mockReturnValueOnce(
YAML.stringify({
...metadata,
inputs: {
enabled: { description: 'Enabled', default: false, required: false },
retries: { description: 'Retries', default: 3 },
optional: { default: null, description: null },
},
outputs: { result: { description: null } },
}),
);
const { default: Action } = await import('../src/Action.js');
const action = new Action(actTestYmlPath, mockLogTask);
expect(action.inputs?.enabled?.default).toBe(false);
Expand Down
219 changes: 219 additions & 0 deletions __tests__/integration-readme-contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,225 @@ describe('README generation contract', () => {
expect(sectionBody(await generate(), 'outputs')).toContain(key);
});

it.each([undefined, '', 'null'])(
'renders and verifies empty descriptions for YAML descriptions written as %j',
async (description) => {
const descriptionLine =
description === undefined ? '' : ` description: ${description}\n`;
fs.writeFileSync(
actionPath,
`name: Nullish description action
description: Metadata with empty descriptions
inputs:
option:
${descriptionLine} default: false
outputs:
result:
${descriptionLine} value: output
runs:
using: node24
main: index.js
`,
);
const before = 'Keep this prefix.\n';
const after = '\nKeep this suffix.\n';
const originalReadme = `${before}${[
'<!-- start usage -->',
'<!-- end usage -->',
'<!-- start inputs -->',
'<!-- end inputs -->',
'<!-- start outputs -->',
'<!-- end outputs -->',
'',
].join('\n')}${after}`;
fs.writeFileSync(readmePath, originalReadme);
fs.writeFileSync(
path.join(tempDir, '.ghadocs.json'),
JSON.stringify({
owner: 'contract-owner',
repo: 'contract-repo',
paths: { action: actionPath, readme: readmePath },
sections: ['usage', 'inputs', 'outputs'],
prettier: false,
versioning: { source: 'explicit', override: '1.2.3', prefix: 'v', badge: false },
}),
);

const readme = await generate(false);

for (const section of ['inputs', 'outputs']) {
const rows = sectionBody(readme, section)
.split('\n')
.filter((line) => line.startsWith('|'));
const cells = rows[2]
.split('|')
.slice(1, -1)
.map((cell) => cell.trim());
expect(cells[0]).toBe(
section === 'inputs' ? '<b><code>option</code></b>' : '<b><code>result</code></b>',
);
expect(cells[1]).toBe('');
}
expect(sectionBody(readme, 'inputs')).toContain('<code>false</code>');
expect(sectionBody(readme, 'usage')).toContain("option: ''");
expect(
sectionBody(readme, 'usage')
.match(/^\s*# Description:.*$/gmu)
?.map((line) => line.trim()),
).toEqual(['# Description:']);
expect(readme.startsWith(before)).toBe(true);
expect(readme.endsWith(after)).toBe(true);

const verify = (candidate: string) => {
fs.writeFileSync(readmePath, candidate);
return spawnSync(
process.execPath,
[VERIFIER_SCRIPT, actionPath, readmePath, path.join(tempDir, 'README.original.md')],
{ cwd: tempDir, encoding: 'utf8' },
);
};
fs.writeFileSync(path.join(tempDir, 'README.original.md'), originalReadme);
const valid = verify(readme);
expect(valid.status, `${valid.stdout}\n${valid.stderr}`).toBe(0);
expect(valid.stdout).toContain('All contract checks passed');

const staleDescription = readme.replace(' # Description:', ' # Description: stale');
expect(staleDescription).not.toBe(readme);
const stale = verify(staleDescription);
expect(stale.status).not.toBe(0);
expect(stale.stdout).toContain('usage comments do not exactly project action.yml');
},
);

it.each([false, true])(
'renders and verifies declared default values with pretty %s',
async (pretty) => {
fs.writeFileSync(
actionPath,
`name: Declared defaults action
description: Metadata with supported default values
inputs:
absent_default:
description: No default
empty_default:
description: Empty default
default: ''
null_default:
description: Null default
default: null
false_default:
description: False default
default: false
zero_default:
description: Zero default
default: 0
multiline_default:
description: Multiline default
default: |-
first
second
blank_line_default:
description: Blank line default
default: |-
before

after
runs:
using: node24
main: index.js
`,
);
const originalReadme = [
'Keep this prefix. ',
'<!-- start usage -->',
'<!-- end usage -->',
'<!-- start inputs -->',
'<!-- end inputs -->',
'Keep this suffix. ',
'',
].join('\n');
const originalReadmePath = path.join(tempDir, 'README.original.md');
fs.writeFileSync(readmePath, originalReadme);
fs.writeFileSync(originalReadmePath, originalReadme);
fs.writeFileSync(
path.join(tempDir, '.ghadocs.json'),
JSON.stringify({
owner: 'contract-owner',
repo: 'contract-repo',
paths: { action: actionPath, readme: readmePath },
sections: ['usage', 'inputs'],
prettier: pretty,
versioning: { source: 'explicit', override: '1.2.3', prefix: 'v', badge: false },
}),
);

const readme = await generate(pretty);
const usage = sectionBody(readme, 'usage');
expect(
usage
.split('\n')
.map((line) => line.trim())
.filter((line) => line.startsWith('# Default:')),
).toEqual([
'# Default:',
'# Default: null',
'# Default: false',
'# Default: 0',
'# Default: first',
'# Default: before',
]);
expect(usage).toContain(' # Default: first\n # second');
expect(usage).toContain(' # Default: before\n #\n # after');
const defaultCells = sectionBody(readme, 'inputs')
.split('\n')
.filter((line) => line.startsWith('|'))
.slice(2)
.map((line) => line.split('|')[3].trim());
expect(defaultCells).toEqual([
'',
'',
'<code>null</code>',
'<code>false</code>',
'<code>0</code>',
'<code>first<br />second</code>',
'<code>before<br /><br />after</code>',
]);

const verify = (candidate: string) => {
fs.writeFileSync(readmePath, candidate);
return spawnSync(
process.execPath,
[VERIFIER_SCRIPT, actionPath, readmePath, originalReadmePath],
{ cwd: tempDir, encoding: 'utf8' },
);
};
const valid = verify(readme);
expect(valid.status, `${valid.stdout}\n${valid.stderr}`).toBe(0);
expect(valid.stdout).toContain('All contract checks passed');

const corruptions = [
{
readme: readme.replace(' # Default:\n', ' # Default: stale\n'),
diagnostic: 'usage comments do not exactly project action.yml: empty_default',
},
{
readme: readme.replace('<code>null</code>', ''),
diagnostic: '`null_default` default in the inputs table does not match action.yml',
},
{
readme: readme.replace(' # second', ' # second'),
diagnostic: 'usage comments do not exactly project action.yml: multiline_default',
},
];
for (const corruption of corruptions) {
expect(corruption.readme).not.toBe(readme);
const invalid = verify(corruption.readme);
expect(invalid.status).not.toBe(0);
expect(invalid.stdout).toContain(corruption.diagnostic);
}
},
);

it('ignores leading blank lines before selecting input and output table paragraphs', async () => {
const action = ACTION_YML.replace(
'description: A plain input with a default',
Expand Down
67 changes: 67 additions & 0 deletions __tests__/save.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';

import { afterEach, describe, expect, it, vi } from 'vite-plus/test';

import { GHActionDocsConfig } from '../src/config.js';
import type Inputs from '../src/inputs.js';
import LogTask from '../src/logtask/index.js';
import save from '../src/save.js';

function inputsWithSave(saveEnabled: boolean, configPath: string): Inputs {
const config = {
owner: 'fixture-owner',
repo: 'fixture-repo',
save: saveEnabled,
};

return {
config: { get: vi.fn(() => config) },
configPath,
} as unknown as Inputs;
}

describe('save', () => {
afterEach(() => {
vi.restoreAllMocks();
});

it('persists the resolved config when save is enabled', async () => {
const tempDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'ghadocs-save-'));
const configPath = path.join(tempDirectory, '.ghadocs.json');

try {
await save(inputsWithSave(true, configPath), new LogTask('save-test'));

expect(JSON.parse(fs.readFileSync(configPath, 'utf8'))).toMatchObject({
owner: 'fixture-owner',
repo: 'fixture-repo',
});
} finally {
fs.rmSync(tempDirectory, { recursive: true, force: true });
}
});

it('rejects with the original persistence error when save is enabled', async () => {
const failure = new Error('config persistence failed');
const persist = vi.spyOn(GHActionDocsConfig.prototype, 'save').mockRejectedValue(failure);
const configPath = path.join(os.tmpdir(), 'ghadocs-save-failure', '.ghadocs.json');

await expect(save(inputsWithSave(true, configPath), new LogTask('save-test'))).rejects.toBe(
failure,
);
expect(persist).toHaveBeenCalledWith(configPath);
});

it('does not attempt persistence when save is disabled', async () => {
const persist = vi.spyOn(GHActionDocsConfig.prototype, 'save');

await save(
inputsWithSave(false, path.join(os.tmpdir(), 'ghadocs-save-disabled', '.ghadocs.json')),
new LogTask('save-test'),
);

expect(persist).not.toHaveBeenCalled();
});
});
19 changes: 13 additions & 6 deletions scripts/verify-readme-contract.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -599,16 +599,20 @@ if (usage === null) {
.join('\n');

const generatedUsageComments = async (declaration) => {
const formatted = await format(`Description: ${declaration?.description}`, {
const formatted = await format(`Description: ${declaration?.description ?? ''}`, {
Comment thread
Jamie-BitFlight marked this conversation as resolved.
semi: false,
parser: 'markdown',
proseWrap: 'always',
plugins: [markdown, yamlPlugin],
});
const lines = formatted.split('\n');
if (declaration?.default !== undefined) {
const defaultLine = `Default: ${declaration.default}`;
lines.push(prettierEnabled ? defaultLine.trimEnd() : defaultLine);
const label = 'Default: ';
const [first, ...continuations] = String(declaration.default).split(/\r\n|\n|\r/);
lines.push(`${label}${first}`.trimEnd());
for (const line of continuations) {
lines.push(`${' '.repeat(label.length)}${line}`.trimEnd());
}
}
return lines.join('\n');
};
Expand Down Expand Up @@ -737,9 +741,12 @@ await tableSection('inputs', inputs, (declaration) => [
{ label: 'description', expected: firstParagraph(declaration?.description), markdown: true },
{
label: 'default',
// An empty-string default is indistinguishable from no default once it is
// in a table cell, so it is not asserted. `false` and `0` are.
expected: declaration?.default === undefined || declaration.default === '' ? '' : declaration.default,
// Missing and empty-string defaults have empty cells. Every other declared
// value is rendered as text, including `false`, `0`, and `null`.
expected:
declaration?.default === undefined || declaration.default === ''
? ''
: String(declaration.default),
code: 'html',
},
// The generator emits the flag emphasised — `update-inputs.ts` writes
Expand Down
Loading
Loading