diff --git a/__tests__/action.test.ts b/__tests__/action.test.ts index f81848ca..fe6d94a0 100644 --- a/__tests__/action.test.ts +++ b/__tests__/action.test.ts @@ -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 } }, @@ -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); diff --git a/__tests__/integration-readme-contract.test.ts b/__tests__/integration-readme-contract.test.ts index 54162947..08bf6154 100644 --- a/__tests__/integration-readme-contract.test.ts +++ b/__tests__/integration-readme-contract.test.ts @@ -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}${[ + '', + '', + '', + '', + '', + '', + '', + ].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' ? 'option' : 'result', + ); + expect(cells[1]).toBe(''); + } + expect(sectionBody(readme, 'inputs')).toContain('false'); + 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. ', + '', + '', + '', + '', + '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([ + '', + '', + 'null', + 'false', + '0', + 'first
second
', + 'before

after
', + ]); + + 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('null', ''), + 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', diff --git a/__tests__/save.test.ts b/__tests__/save.test.ts new file mode 100644 index 00000000..6dc494c4 --- /dev/null +++ b/__tests__/save.test.ts @@ -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(); + }); +}); diff --git a/scripts/verify-readme-contract.mjs b/scripts/verify-readme-contract.mjs index 9b3016f8..89e6d49e 100644 --- a/scripts/verify-readme-contract.mjs +++ b/scripts/verify-readme-contract.mjs @@ -599,7 +599,7 @@ if (usage === null) { .join('\n'); const generatedUsageComments = async (declaration) => { - const formatted = await format(`Description: ${declaration?.description}`, { + const formatted = await format(`Description: ${declaration?.description ?? ''}`, { semi: false, parser: 'markdown', proseWrap: 'always', @@ -607,8 +607,12 @@ if (usage === null) { }); 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'); }; @@ -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 diff --git a/src/Action.ts b/src/Action.ts index c54d3647..31aceb4d 100644 --- a/src/Action.ts +++ b/src/Action.ts @@ -135,8 +135,7 @@ export default class Action implements ActionYaml { (isRecord(value) && Object.values(value).every( (entry) => - isRecord(entry) && - (entry.description == null || typeof entry.description === 'string'), + isRecord(entry) && (entry.description == null || typeof entry.description === 'string'), )); if (!validDescriptions(record.inputs) || !validDescriptions(record.outputs)) { return false; diff --git a/src/sections/update-usage.ts b/src/sections/update-usage.ts index a2130d5e..5ab4fc8a 100644 --- a/src/sections/update-usage.ts +++ b/src/sections/update-usage.ts @@ -43,7 +43,11 @@ export default async function updateUsage( for (const key of Object.keys(inp)) { const input = inp[key]; if (input !== undefined) { - descriptionPromises[key] = wrapDescription(`Description: ${input.description}`, [], indent); + descriptionPromises[key] = wrapDescription( + `Description: ${input.description ?? ''}`, + [], + indent, + ); } }