diff --git a/__tests__/action.test.ts b/__tests__/action.test.ts index 32a93306..f81848ca 100644 --- a/__tests__/action.test.ts +++ b/__tests__/action.test.ts @@ -137,6 +137,44 @@ describe('Action', () => { }); }); + describe('nested metadata validation', () => { + const metadata = { + name: 'Fixture action', + description: 'Fixture description', + runs: { using: 'node24', main: 'index.js' }, + }; + + it.each([ + { inputs: { option: null } }, + { inputs: { option: 42 } }, + { inputs: { option: { description: 42 } } }, + { outputs: { result: null } }, + { outputs: { result: { description: false } } }, + { inputs: 'not a mapping' }, + ])('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'); + }); + + 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 } }, + })); + const { default: Action } = await import('../src/Action.js'); + const action = new Action(actTestYmlPath, mockLogTask); + expect(action.inputs?.enabled?.default).toBe(false); + expect(action.inputs?.retries?.default).toBe(3); + expect(action.outputs?.result?.description).toBeNull(); + }); + }); + describe('inputDefault', () => { it('should return the default value for an input', async () => { const { default: Action } = await import('../src/Action.js'); diff --git a/src/Action.ts b/src/Action.ts index d55dad20..c54d3647 100644 --- a/src/Action.ts +++ b/src/Action.ts @@ -128,6 +128,19 @@ export default class Action implements ActionYaml { return false; } const record = obj as Record; + const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + const validDescriptions = (value: unknown): boolean => + value === undefined || + (isRecord(value) && + Object.values(value).every( + (entry) => + isRecord(entry) && + (entry.description == null || typeof entry.description === 'string'), + )); + if (!validDescriptions(record.inputs) || !validDescriptions(record.outputs)) { + return false; + } if ('name' in record && 'description' in record && 'runs' in record) { const runs = record.runs; if (typeof runs === 'object' && runs !== null && !Array.isArray(runs) && 'using' in runs) {