diff --git a/.github/workflows/e2e.yaml b/.github/workflows/e2e.yaml new file mode 100644 index 0000000..30429d3 --- /dev/null +++ b/.github/workflows/e2e.yaml @@ -0,0 +1,61 @@ +name: E2E (self-dogfood) + +on: + issue_comment: + types: [created, edited] + discussion_comment: + types: [created, edited] + pull_request: + types: [opened, synchronize, reopened] + push: + branches: + - main + +permissions: + contents: read + +concurrency: + group: e2e-${{ github.event.comment.id || github.ref }} + cancel-in-progress: false + +jobs: + registered: + if: github.event_name == 'pull_request' || github.event_name == 'push' + runs-on: ubuntu-latest + steps: + - run: echo "E2E workflow is registered. Real dogfood runs only on issue_comment / discussion_comment." + + dogfood: + if: | + (github.event_name == 'issue_comment' || github.event_name == 'discussion_comment') && + startsWith(github.event.comment.body, '.') + runs-on: ubuntu-latest + environment: e2e + permissions: + contents: read + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - uses: ./.github/actions/setup + + - run: pnpm build + + - id: command + uses: ./ + continue-on-error: true + with: + command: test + allowed_contexts: issue,pull_request,discussion + + - name: Verify + env: + ACTUAL_OUTCOME: ${{ steps.command.outcome }} + ACTUAL_CONTINUE: ${{ steps.command.outputs.continue }} + ACTUAL_COMMAND: ${{ steps.command.outputs.command }} + ACTUAL_PARAMS: ${{ steps.command.outputs.params }} + ACTUAL_NUMBER: ${{ steps.command.outputs.number }} + ACTUAL_CONTEXT: ${{ steps.command.outputs.context }} + ACTUAL_ISSUE_NUMBER: ${{ steps.command.outputs.issue_number }} + ACTUAL_COMMENT_ID: ${{ steps.command.outputs.comment_id }} + ACTUAL_ACTOR: ${{ steps.command.outputs.actor }} + run: pnpm tsx scripts/e2e-verify.ts diff --git a/README.md b/README.md index d8ddba7..d00010d 100644 --- a/README.md +++ b/README.md @@ -275,6 +275,10 @@ $ mise install $ pnpm i ``` +### E2E (self-dogfood) + +The action is validated against real GitHub event payloads via an environment-gated workflow. See [`docs/e2e.md`](./docs/e2e.md) for the comment convention, security model, and required repo settings. + ## LICENSE See [LICENSE][license]. diff --git a/docs/e2e.md b/docs/e2e.md new file mode 100644 index 0000000..461baad --- /dev/null +++ b/docs/e2e.md @@ -0,0 +1,203 @@ +# E2E (self-dogfood) workflow + +The workflow at `.github/workflows/e2e.yaml` exercises the bundled `dist/index.js` +against real GitHub event payloads from this repository. It runs the action under +test against `issue_comment` (on issues and on PRs) and `discussion_comment` +events, and asserts the emitted outputs against an expectation directive embedded +in the triggering comment. + +The matrix of `vitest` integration tests in `src/main.integration.test.ts` provides +fast, exhaustive coverage. This workflow complements that suite by validating the +real runtime path (`runs.using: node24` + payload shape) before a release ships. + +## Comment convention + +The directive is carried as a single-quoted JSON string under the reserved +`_expect_` key in the command's params, so the action's own parser sees it as a +regular params value: + +``` +.test foo=bar, _expect_='{"continue":"true","command":"test","params":{"foo":"bar"},"context":"issue"}' +``` + +The action parses this comment as `command=test, params={foo:"bar", _expect_:""}` +and emits the full `params` (including `_expect_`) as the `params` output. The +verification step then extracts `params._expect_`, JSON-decodes it, and compares +against the other observable outputs. + +### Why not `# expect: ...` on a separate line? + +The action's parser is strict: anything in the comment body after `.command +key=value, ...` that isn't a comma-separated key=value pair (or end-of-input) +causes a parse error and `core.setFailed`. A trailing `# expect:` line breaks +the parser before verification can run. Putting the directive inside a params +value is the only way to keep the directive in the same comment without +modifying the production parser. + +### Rules + +- The directive value is **a JSON string**, not raw JSON. Quote it with single + quotes so the embedded double-quoted JSON does not collide with the param's + delimiter. +- If the comment has no `_expect_` key (or the action did not emit `params` at + all), the verification step falls back to a smoke check. The smoke check + asserts that the action emitted a `continue` output and that its step outcome + is not `failure`. +- Keys not listed in the directive are not checked. +- Unknown directive keys are reported as a mismatch (typo guard). The allowed + keys are `failed`, `params`, `continue`, `command`, `number`, `context`, + `issue_number`, `comment_id`, and `actor`. The internal `outcome` is asserted + via `failed` rather than directly. +- The `failed` value must be a JSON boolean (`true` or `false`). A string + `"true"` is reported as a mismatch (typo guard). +- A directive value of `null` means **must be unset** — the corresponding + output must be empty. For example, `"issue_number": null` asserts that the + action did not emit `issue_number` (regression guard for the soft-deprecation + contract on `discussion_comment` events). +- The `_expect_` key is stripped from `actual.params` before comparison, so + directive-bearing comments do not need to enumerate it under + `expected.params`. +- Numeric JSON values are coerced via `String(...)` before comparison + (GitHub Actions outputs are always strings). + +### Examples + +Positive case (action must succeed with the listed params): + +``` +.test name=alice, _expect_='{"continue":"true","command":"test","params":{"name":"alice"},"context":"issue"}' +``` + +Smoke run (no directive — only verifies the action ran end-to-end): + +``` +.test foo=bar +``` + +### Limitations of the directive carrier + +The directive can only be carried on comments that produce `continue=true`, +because the action only emits the `params` output on that path +(`src/main.ts:110-112`). For negative cases — filtered `allowed_contexts`, +command not in the allow-list, malformed params — the directive cannot reach +the verification step. Use the smoke run convention for those, and confirm the +filter behavior manually in the workflow log (Verify step shows +`actual.continue` and `actual.outcome`). + +## Security model + +The primary security boundary is the GitHub `Environment` named `e2e`, configured +with required reviewers. Every workflow run pauses until a reviewer explicitly +approves it. + +- Workflow source guarantee: GitHub Actions runs `issue_comment` and + `discussion_comment` workflows from the repository default branch. A fork PR + cannot mutate the e2e workflow file or scripts in a way that affects the run. +- Token scope: the workflow runs with the default `GITHUB_TOKEN` and an explicit + `permissions: contents: read`. No personal access tokens or app installations + are used. +- No secret usage in the action under test: the action only parses public + comment text. A leaked approval has limited blast radius — the worst case is + a CI minute burned on parsing a comment. + +### Approval-then-edit race (known caveat) + +A comment author can edit the comment body between the moment a reviewer +approves the `e2e` environment and the moment the workflow runs. The workflow +re-reads `github.event.comment.body` at run start (via the action's own payload +access), so an edited directive will be evaluated, not the body the reviewer +saw. + +Mitigation is operational rather than code-side: + +- Reviewers should screenshot or paste the comment body at approval time and + compare it with the workflow's Verify step output. +- The risk is bounded because the e2e job has no write permissions and the + action under test parses only public comment text. + +A code-side mitigation (re-reading the comment body via `gh api` after +approval) is intentionally out of scope; it would require additional +`pull-requests: write` or `issues: write` scope that contradicts the +minimal-permissions design. + +## Required repo settings + +These are one-time admin tasks. Without them, the workflow will fail to start. + +1. **Enable Discussions** on `knowledge-work/command-action`. + Settings → General → Features → check `Discussions`. + +2. **Create the `e2e` Environment**. + Settings → Environments → `New environment` → name `e2e` → enable + `Required reviewers` and add the `knowledge-work` org members who maintain + this repo as required reviewers. + +The list of reviewers should match the org members who already approve releases. + +## Operational tradeoffs + +By design, the `dogfood` job only runs when the comment body starts with a `.` +character (`startsWith(github.event.comment.body, '.')`). This filters out chat +or review comments so that the reviewer is not asked to approve the `e2e` +environment every time someone posts an unrelated comment. + +The alternative (running on every comment) was considered and rejected because +it produces approval noise that drowns out real dogfood events. + +If org policy or operational preferences change, this trade-off can be revisited +by editing the `dogfood.if` expression in `.github/workflows/e2e.yaml`. + +## Smoke run + +After Discussions and the `e2e` environment are configured, exercise each event +kind once with a passing directive and once with a deliberately wrong directive. + +### Positive runs + +1. **Issue comment** — open or use an existing issue and post: + + ``` + .test foo=bar, _expect_='{"continue":"true","command":"test","params":{"foo":"bar"},"context":"issue"}' + ``` + + Approve the `e2e` environment when prompted; confirm the workflow run passes. + +2. **PR comment** — open or use an existing PR and post the same comment, but + change `"context":"issue"` to `"context":"pull_request"`. Approve and verify. + +3. **Discussion comment** — open or use a discussion and post the same comment, + but with `"context":"discussion"` and add `"issue_number":null` (the soft- + deprecation regression guard — `issue_number` must be unset on discussions). + +### Negative runs + +For each of the three event kinds, post a comment with a deliberately wrong +directive (for example, change one of the asserted values in the directive). +The workflow must fail with a clear `E2E mismatch:` message naming the failing +field. + +### What "complete" looks like + +- Six total dogfood runs (3 event kinds × positive + negative). +- All three positive runs end with `E2E expectations matched.` in the + Verify step. +- All three negative runs fail in the Verify step with a clear mismatch + message. + +A failure outside the Verify step (for example, the action step itself crashed) +indicates a real regression in the action, not in the harness. + +## How verification works + +The verification logic lives in two files: + +- `src/e2e-verify.ts` — pure functions (`extractExpectDirective`, `compare`) + with unit tests in `src/e2e-verify.test.ts`. The runtime ships nothing here; + these are test-only utilities re-used by the workflow. +- `scripts/e2e-verify.ts` — the CLI shim invoked by the workflow's Verify + step. It reads environment variables (`ACTUAL_*`), calls the pure functions, + and exits non-zero on any mismatch. + +`dist/` drift is not re-asserted by this workflow because the existing +`.github/workflows/ci.yaml` job already gates it on every PR and every push to +`main`. diff --git a/scripts/e2e-verify.ts b/scripts/e2e-verify.ts new file mode 100644 index 0000000..5a7cfc0 --- /dev/null +++ b/scripts/e2e-verify.ts @@ -0,0 +1,48 @@ +import process from 'node:process'; +import { type ActualOutputs, compare, extractExpectDirective } from '../src/e2e-verify.js'; + +const env = (key: string): string => process.env[key] ?? ''; + +const actual: ActualOutputs = { + outcome: env('ACTUAL_OUTCOME'), + continue: env('ACTUAL_CONTINUE'), + command: env('ACTUAL_COMMAND'), + params: env('ACTUAL_PARAMS'), + number: env('ACTUAL_NUMBER'), + context: env('ACTUAL_CONTEXT'), + issue_number: env('ACTUAL_ISSUE_NUMBER'), + comment_id: env('ACTUAL_COMMENT_ID'), + actor: env('ACTUAL_ACTOR'), +}; + +let directive: Record | null; +try { + directive = extractExpectDirective(actual.params); +} catch (e) { + console.error(`Failed to parse the dogfood directive — ${(e as Error).message}`); + process.exit(1); +} + +if (directive === null) { + if (actual.continue === '') { + console.error('Smoke test failed: action emitted no "continue" output.'); + process.exit(1); + } + if (actual.outcome === 'failure') { + console.error('Smoke test failed: action step outcome was "failure" but no directive declared it.'); + process.exit(1); + } + console.log('Smoke test passed (no directive present in params).'); + process.exit(0); +} + +const mismatches = compare(directive, actual); +if (mismatches.length > 0) { + console.error('E2E mismatch:'); + for (const m of mismatches) { + console.error(' - ' + m); + } + process.exit(1); +} + +console.log('E2E expectations matched.'); diff --git a/src/e2e-verify.test.ts b/src/e2e-verify.test.ts new file mode 100644 index 0000000..48d1b88 --- /dev/null +++ b/src/e2e-verify.test.ts @@ -0,0 +1,181 @@ +import { describe, expect, test } from 'vitest'; +import { type ActualOutputs, compare, E2E_DIRECTIVE_KEY, extractExpectDirective } from './e2e-verify.js'; + +const baseActual = (overrides: Partial = {}): ActualOutputs => ({ + outcome: 'success', + continue: '', + command: '', + params: '', + number: '', + context: '', + issue_number: '', + comment_id: '', + actor: '', + ...overrides, +}); + +const paramsWithDirective = (other: Record, directive: Record): string => + JSON.stringify({ ...other, [E2E_DIRECTIVE_KEY]: JSON.stringify(directive) }); + +describe('extractExpectDirective', () => { + test('returns null on empty paramsJson (action did not emit params)', () => { + expect(extractExpectDirective('')).toBeNull(); + }); + + test('returns null when paramsJson lacks the directive key', () => { + expect(extractExpectDirective(JSON.stringify({ foo: 'bar' }))).toBeNull(); + }); + + test('parses an embedded directive value', () => { + const params = paramsWithDirective({ foo: 'bar' }, { continue: 'true', command: 'test' }); + expect(extractExpectDirective(params)).toEqual({ continue: 'true', command: 'test' }); + }); + + test('throws SyntaxError when the outer params JSON is malformed', () => { + expect(() => extractExpectDirective('not json')).toThrow(SyntaxError); + }); + + test('throws SyntaxError when the inner directive JSON is malformed', () => { + const params = JSON.stringify({ [E2E_DIRECTIVE_KEY]: '{ "continue":' }); + expect(() => extractExpectDirective(params)).toThrow(SyntaxError); + }); + + test('throws TypeError when params is not a plain object (array)', () => { + expect(() => extractExpectDirective(JSON.stringify([1, 2, 3]))).toThrow(TypeError); + }); + + test('throws TypeError when the directive value is not a string', () => { + const params = JSON.stringify({ [E2E_DIRECTIVE_KEY]: 42 }); + expect(() => extractExpectDirective(params)).toThrow(TypeError); + }); + + test('throws TypeError when the directive payload is an array', () => { + const params = JSON.stringify({ [E2E_DIRECTIVE_KEY]: JSON.stringify([1, 2, 3]) }); + expect(() => extractExpectDirective(params)).toThrow(TypeError); + }); +}); + +describe('compare', () => { + test('returns empty array when every expected field matches', () => { + const actual = baseActual({ + outcome: 'success', + continue: 'true', + command: 'test', + params: paramsWithDirective({ foo: 'bar' }, {}), + context: 'issue', + number: '42', + issue_number: '42', + }); + const mismatches = compare( + { + continue: 'true', + command: 'test', + params: { foo: 'bar' }, + context: 'issue', + number: 42, + }, + actual, + ); + expect(mismatches).toEqual([]); + }); + + test('failed:true requires outcome=failure', () => { + const actual = baseActual({ outcome: 'failure', continue: 'false' }); + expect(compare({ failed: true, continue: 'false' }, actual)).toEqual([]); + }); + + test('failed:false requires outcome=success', () => { + const actual = baseActual({ outcome: 'success', continue: 'true' }); + expect(compare({ failed: false, continue: 'true' }, actual)).toEqual([]); + }); + + test('failed:true with outcome=success produces a mismatch', () => { + const actual = baseActual({ outcome: 'success', continue: 'true' }); + const mismatches = compare({ failed: true }, actual); + expect(mismatches).toHaveLength(1); + expect(mismatches[0]).toMatch(/failed.*outcome was "success"/); + }); + + test('failed absent + outcome=failure produces a mismatch (regression guard)', () => { + const actual = baseActual({ outcome: 'failure', continue: 'false' }); + const mismatches = compare({ continue: 'false' }, actual); + expect(mismatches).toHaveLength(1); + expect(mismatches[0]).toMatch(/outcome was "failure".*did not declare `failed: true`/); + }); + + test('non-boolean failed value is reported as a mismatch (string value typo guard)', () => { + const actual = baseActual({ outcome: 'success', continue: 'true' }); + const mismatches = compare({ failed: 'true' as unknown as boolean }, actual); + expect(mismatches).toHaveLength(1); + expect(mismatches[0]).toMatch(/^failed: directive value must be a boolean/); + }); + + test('non-boolean failed (number) is reported as a mismatch', () => { + const actual = baseActual({ outcome: 'success' }); + const mismatches = compare({ failed: 1 as unknown as boolean }, actual); + expect(mismatches).toHaveLength(1); + expect(mismatches[0]).toMatch(/^failed: directive value must be a boolean/); + }); + + test('params deep-equal mismatch is reported (directive key stripped from actual)', () => { + const actual = baseActual({ + outcome: 'success', + params: paramsWithDirective({ foo: 'bar' }, {}), + }); + const mismatches = compare({ params: { foo: 'baz' } }, actual); + expect(mismatches).toHaveLength(1); + expect(mismatches[0]).toMatch(/^params:/); + }); + + test('params deep-equal match with nested objects, numbers, and null (directive key stripped)', () => { + const actual = baseActual({ + outcome: 'success', + params: paramsWithDirective({ s: 'hi', n: 42, b: true, x: null, nested: { k: ['a', 'b'] } }, {}), + }); + const mismatches = compare({ params: { s: 'hi', n: 42, b: true, x: null, nested: { k: ['a', 'b'] } } }, actual); + expect(mismatches).toEqual([]); + }); + + test('null sentinel: issue_number=null matches empty actual', () => { + const actual = baseActual({ outcome: 'success', issue_number: '' }); + expect(compare({ issue_number: null }, actual)).toEqual([]); + }); + + test('null sentinel: issue_number=null mismatches a populated actual', () => { + const actual = baseActual({ outcome: 'success', issue_number: '42' }); + const mismatches = compare({ issue_number: null }, actual); + expect(mismatches).toHaveLength(1); + expect(mismatches[0]).toMatch(/issue_number.*expected to be unset.*got "42"/); + }); + + test('String coercion: numeric JSON expected value compares against string actual', () => { + const actual = baseActual({ outcome: 'success', number: '42' }); + expect(compare({ number: 42 }, actual)).toEqual([]); + }); + + test('continue: "true" matches string actual', () => { + const actual = baseActual({ outcome: 'success', continue: 'true' }); + expect(compare({ continue: 'true' }, actual)).toEqual([]); + }); + + test('unknown key in expected is reported as a mismatch (typo guard)', () => { + const actual = baseActual({ outcome: 'success', continue: 'true' }); + const mismatches = compare({ continue: 'true', mistyped_key: 'whatever' }, actual); + expect(mismatches).toHaveLength(1); + expect(mismatches[0]).toMatch(/^mistyped_key: unknown directive key/); + }); + + test('outcome is not a valid directive key (users assert via `failed`)', () => { + const actual = baseActual({ outcome: 'success', continue: 'true' }); + const mismatches = compare({ outcome: 'success' }, actual); + expect(mismatches).toHaveLength(1); + expect(mismatches[0]).toMatch(/^outcome: unknown directive key/); + }); + + test('expected params with an unset (empty) actual.params is reported as a mismatch', () => { + const actual = baseActual({ outcome: 'success', continue: 'false', params: '' }); + const mismatches = compare({ continue: 'false', params: {} }, actual); + expect(mismatches).toHaveLength(1); + expect(mismatches[0]).toMatch(/^params: expected the action to emit params, but the params output was unset/); + }); +}); diff --git a/src/e2e-verify.ts b/src/e2e-verify.ts new file mode 100644 index 0000000..74e425d --- /dev/null +++ b/src/e2e-verify.ts @@ -0,0 +1,176 @@ +// Pure functions for the self-dogfood e2e verification step. +// No console, no process.exit — the CLI shim under scripts/ handles I/O. + +export type ActualOutputs = { + outcome: string; + continue: string; + command: string; + params: string; + number: string; + context: string; + issue_number: string; + comment_id: string; + actor: string; +}; + +// The dogfood directive lives inside the comment's `params` block under this key, +// e.g. `.test foo=bar, _expect_='{"continue":"true",...}'`. This shape keeps the +// directive parseable by the action's own parser (the only thing it can route +// through is a valid params value), at the cost of reserving `_expect_` as a +// dogfood-only key. The trade-off: directives can only be carried on comments +// that produce `continue=true` (the action emits `params` only on that path). +// Negative cases (filtered context, unknown command, malformed params) fall back +// to the smoke check — see docs/e2e.md. +export const E2E_DIRECTIVE_KEY = '_expect_'; + +const PARAMS_KEY = 'params'; +const FAILED_KEY = 'failed'; + +// Directive keys that map to ActualOutputs scalar fields. `outcome` is intentionally +// excluded — users assert the action step's outcome via `failed`, not directly. +const SCALAR_DIRECTIVE_KEYS = ['continue', 'command', 'number', 'context', 'issue_number', 'comment_id', 'actor']; + +const ALLOWED_DIRECTIVE_KEYS = new Set([FAILED_KEY, PARAMS_KEY, ...SCALAR_DIRECTIVE_KEYS]); + +/** + * Extract the dogfood directive from the action's `params` output JSON. + * - Returns null when `paramsJson` is empty (action did not emit params, e.g. continue=false paths). + * - Returns null when the parsed params object does not contain `directiveKey`. + * - Throws SyntaxError if `paramsJson` itself is not valid JSON. + * - Throws SyntaxError if `params[directiveKey]` is not valid JSON. + * - Throws TypeError if either decoded value is not a plain object. + */ +export const extractExpectDirective = ( + paramsJson: string, + directiveKey: string = E2E_DIRECTIVE_KEY, +): Record | null => { + if (paramsJson === '') return null; + const params: unknown = JSON.parse(paramsJson); + if (typeof params !== 'object' || params === null || Array.isArray(params)) { + throw new TypeError('action params output must decode to a plain JSON object'); + } + const directiveRaw = (params as Record)[directiveKey]; + if (directiveRaw === undefined) return null; + if (typeof directiveRaw !== 'string') { + throw new TypeError(`params.${directiveKey} must be a JSON string`); + } + const directive: unknown = JSON.parse(directiveRaw); + if (typeof directive !== 'object' || directive === null || Array.isArray(directive)) { + throw new TypeError(`params.${directiveKey} must decode to a plain JSON object`); + } + return directive as Record; +}; + +const deepEqual = (a: unknown, b: unknown): boolean => { + if (Object.is(a, b)) return true; + if (a === null || b === null) return false; + if (typeof a !== 'object' || typeof b !== 'object') return false; + if (Array.isArray(a) !== Array.isArray(b)) return false; + if (Array.isArray(a) && Array.isArray(b)) { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) { + if (!deepEqual(a[i], b[i])) return false; + } + return true; + } + const ao = a as Record; + const bo = b as Record; + const aKeys = Object.keys(ao); + const bKeys = Object.keys(bo); + if (aKeys.length !== bKeys.length) return false; + for (const k of aKeys) { + if (!Object.prototype.hasOwnProperty.call(bo, k)) return false; + if (!deepEqual(ao[k], bo[k])) return false; + } + return true; +}; + +/** + * Compare the dogfood directive against the action's observable outputs. + * Returns an array of human-readable mismatch messages — empty array means OK. + * + * When comparing `params`, the directive key (e.g. `_expect_`) is stripped from + * the actual params before deep-equal — the directive lives there only to carry + * itself through the action and is not part of the user-visible params contract. + */ +export const compare = ( + expected: Record, + actual: ActualOutputs, + directiveKey: string = E2E_DIRECTIVE_KEY, +): string[] => { + const mismatches: string[] = []; + + // `failed` value-type validation (boolean only). + if (Object.prototype.hasOwnProperty.call(expected, FAILED_KEY)) { + const failedValue = expected[FAILED_KEY]; + if (typeof failedValue !== 'boolean') { + mismatches.push(`failed: directive value must be a boolean (got ${typeof failedValue})`); + } else if (failedValue === true) { + if (actual.outcome !== 'failure') { + mismatches.push(`failed: expected the action step to fail, but outcome was "${actual.outcome}"`); + } + } else { + if (actual.outcome !== 'success') { + mismatches.push(`failed: expected the action step to succeed, but outcome was "${actual.outcome}"`); + } + } + } else if (actual.outcome === 'failure') { + mismatches.push('failed: action step outcome was "failure" but the directive did not declare `failed: true`'); + } + + for (const key of Object.keys(expected)) { + if (!ALLOWED_DIRECTIVE_KEYS.has(key)) { + mismatches.push( + `${key}: unknown directive key (typo? allowed keys: ${[...ALLOWED_DIRECTIVE_KEYS].sort().join(', ')})`, + ); + continue; + } + + if (key === FAILED_KEY) continue; + + if (key === PARAMS_KEY) { + if (actual.params === '') { + mismatches.push('params: expected the action to emit params, but the params output was unset'); + continue; + } + let parsedActual: unknown; + try { + parsedActual = JSON.parse(actual.params); + } catch (e) { + mismatches.push(`params: actual output is not valid JSON (${(e as Error).message})`); + continue; + } + if (typeof parsedActual !== 'object' || parsedActual === null || Array.isArray(parsedActual)) { + mismatches.push('params: actual output is not a plain JSON object'); + continue; + } + // Strip the directive key from the actual params before deep-equal — + // it is dogfood-internal and not part of the user-visible params contract. + const actualParams = { ...(parsedActual as Record) }; + delete actualParams[directiveKey]; + if (!deepEqual(actualParams, expected[PARAMS_KEY])) { + mismatches.push( + `params: expected ${JSON.stringify(expected[PARAMS_KEY])} but got ${JSON.stringify(actualParams)}`, + ); + } + continue; + } + + const actualValue = actual[key as keyof ActualOutputs]; + const expectedValue = expected[key]; + + if (expectedValue === null) { + if (actualValue !== '') { + mismatches.push(`${key}: expected to be unset (empty string) but got "${actualValue}"`); + } + continue; + } + + const expectedString = String(expectedValue); + if (actualValue !== expectedString) { + mismatches.push(`${key}: expected "${expectedString}" but got "${actualValue}"`); + } + } + + return mismatches; +}; diff --git a/src/main.integration.test.ts b/src/main.integration.test.ts new file mode 100644 index 0000000..556ec40 --- /dev/null +++ b/src/main.integration.test.ts @@ -0,0 +1,340 @@ +import { beforeEach, expect, test, vi } from 'vitest'; +import type { Inputs } from './inputs.js'; + +const mocks = vi.hoisted(() => ({ + setOutput: vi.fn(), + setFailed: vi.fn(), + warning: vi.fn(), + info: vi.fn(), + debug: vi.fn(), + inputs: { command: 'foo', allowed_contexts: 'issue,pull_request,discussion' } satisfies Inputs, + context: { + eventName: 'issue_comment' as string, + payload: {} as Record, + }, +})); + +vi.mock('@actions/core', () => ({ + setOutput: mocks.setOutput, + setFailed: mocks.setFailed, + warning: mocks.warning, + info: mocks.info, + debug: mocks.debug, + getInput: (key: string) => mocks.inputs[key as keyof Inputs] ?? '', +})); + +vi.mock('@actions/github', () => ({ + context: mocks.context, +})); + +const { run } = await import('./main.js'); + +const outputFor = (key: string) => mocks.setOutput.mock.calls.find(([k]) => k === key)?.[1]; + +const matched = (calls: unknown[][], pattern: RegExp): boolean => + calls.some(([message]) => (typeof message === 'string' ? pattern.test(message) : false)); + +type Expected = { + continue: 'true' | 'false'; + command?: string; + params?: Record; + number?: number; + issue_number?: number | 'unset'; + context?: 'issue' | 'pull_request' | 'discussion'; + comment_id?: number; + actor?: string; + failed?: boolean; + warning?: RegExp; + info?: RegExp; + returnCode?: number; +}; + +type Scenario = { + name: string; + eventName: string; + inputs?: Partial; + payload: Record; + expected: Expected; +}; + +const scenarios: Scenario[] = [ + { + name: '01: issue_comment on issue emits context=issue with number === issue_number', + eventName: 'issue_comment', + inputs: { allowed_contexts: 'issue,pull_request' }, + payload: { + issue: { number: 42 }, + comment: { id: 1001, body: '.foo', user: { login: 'alice' } }, + }, + expected: { + continue: 'true', + command: 'foo', + context: 'issue', + number: 42, + issue_number: 42, + comment_id: 1001, + actor: 'alice', + params: {}, + }, + }, + { + name: '02: issue_comment on PR (issue.pull_request set) emits context=pull_request with number === issue_number', + eventName: 'issue_comment', + inputs: { allowed_contexts: 'issue,pull_request' }, + payload: { + issue: { + number: 99, + pull_request: { url: 'https://api.github.com/repos/o/r/pulls/99' }, + }, + comment: { id: 2002, body: '.foo', user: { login: 'bob' } }, + }, + expected: { + continue: 'true', + command: 'foo', + context: 'pull_request', + number: 99, + issue_number: 99, + comment_id: 2002, + actor: 'bob', + params: {}, + }, + }, + { + name: '03: discussion_comment emits context=discussion with number set and issue_number unset (regression guard)', + eventName: 'discussion_comment', + inputs: { allowed_contexts: 'issue,pull_request,discussion' }, + payload: { + discussion: { number: 7 }, + comment: { id: 4004, body: '.foo', user: { login: 'carol' } }, + }, + expected: { + continue: 'true', + command: 'foo', + context: 'discussion', + number: 7, + issue_number: 'unset', + comment_id: 4004, + actor: 'carol', + params: {}, + }, + }, + { + name: '04: issue_comment filtered out when allowed_contexts excludes issue', + eventName: 'issue_comment', + inputs: { allowed_contexts: 'discussion' }, + payload: { + issue: { number: 42 }, + comment: { id: 1001, body: '.foo', user: { login: 'alice' } }, + }, + expected: { + continue: 'false', + info: /not in allowed_contexts/, + }, + }, + { + name: '05: issue_comment on PR filtered out when allowed_contexts excludes pull_request', + eventName: 'issue_comment', + inputs: { allowed_contexts: 'issue' }, + payload: { + issue: { + number: 99, + pull_request: { url: 'https://api.github.com/repos/o/r/pulls/99' }, + }, + comment: { id: 2002, body: '.foo', user: { login: 'bob' } }, + }, + expected: { + continue: 'false', + info: /not in allowed_contexts/, + }, + }, + { + name: '06: discussion_comment filtered out when allowed_contexts excludes discussion', + eventName: 'discussion_comment', + inputs: { allowed_contexts: 'issue,pull_request' }, + payload: { + discussion: { number: 7 }, + comment: { id: 4004, body: '.foo', user: { login: 'carol' } }, + }, + expected: { + continue: 'false', + info: /not in allowed_contexts/, + }, + }, + { + name: '07: unsupported event (push) emits continue=false with a warning naming both supported events', + eventName: 'push', + payload: {}, + expected: { + continue: 'false', + warning: /issue_comment.*discussion_comment/, + }, + }, + { + name: '08: comment body without a command emits continue=false plus an info notice', + eventName: 'issue_comment', + inputs: { allowed_contexts: 'issue,pull_request' }, + payload: { + issue: { number: 42 }, + comment: { id: 1001, body: 'no command here', user: { login: 'alice' } }, + }, + expected: { + continue: 'false', + info: /No command was detected/, + }, + }, + { + name: '09: comment with command outside the allow-list cancels with continue=false plus an info notice', + eventName: 'issue_comment', + inputs: { command: 'foo', allowed_contexts: 'issue,pull_request' }, + payload: { + issue: { number: 42 }, + comment: { id: 1001, body: '.bar', user: { login: 'alice' } }, + }, + expected: { + continue: 'false', + info: /trigger has been canceled/, + }, + }, + { + name: '10: comment with command and no params yields params={}', + eventName: 'issue_comment', + inputs: { command: 'foo', allowed_contexts: 'issue,pull_request' }, + payload: { + issue: { number: 42 }, + comment: { id: 1001, body: '.foo', user: { login: 'alice' } }, + }, + expected: { + continue: 'true', + command: 'foo', + params: {}, + }, + }, + { + name: '11: comment with all parameter value kinds round-trips through params JSON', + eventName: 'issue_comment', + inputs: { command: 'foo', allowed_contexts: 'issue,pull_request' }, + payload: { + issue: { number: 42 }, + comment: { + id: 1001, + body: '.foo s="hello", n=42, b=true, x=null, sl=string-like-value', + user: { login: 'alice' }, + }, + }, + expected: { + continue: 'true', + command: 'foo', + params: { + s: 'hello', + n: 42, + b: true, + x: null, + sl: 'string-like-value', + }, + }, + }, + { + name: '12: malformed params trigger core.setFailed and run() returns 1', + eventName: 'issue_comment', + inputs: { command: 'foo', allowed_contexts: 'issue,pull_request' }, + payload: { + issue: { number: 42 }, + comment: { id: 1001, body: '.foo a=', user: { login: 'alice' } }, + }, + expected: { + continue: 'false', + failed: true, + returnCode: 1, + }, + }, + { + name: '13: invalid allowed_contexts value emits a warning naming the bogus entry and continue=false', + eventName: 'issue_comment', + inputs: { allowed_contexts: 'bogus,issue' }, + payload: { + issue: { number: 42 }, + comment: { id: 1001, body: '.foo', user: { login: 'alice' } }, + }, + expected: { + continue: 'false', + warning: /allowed_contexts.*bogus/, + }, + }, +]; + +beforeEach(() => { + mocks.setOutput.mockReset(); + mocks.setFailed.mockReset(); + mocks.warning.mockReset(); + mocks.info.mockReset(); + mocks.debug.mockReset(); + mocks.inputs.command = 'foo'; + mocks.inputs.allowed_contexts = 'issue,pull_request,discussion'; + mocks.context.eventName = 'issue_comment'; + mocks.context.payload = {}; +}); + +test.each(scenarios)('$name', async (scenario) => { + if (scenario.inputs?.command !== undefined) { + mocks.inputs.command = scenario.inputs.command; + } + if (scenario.inputs?.allowed_contexts !== undefined) { + mocks.inputs.allowed_contexts = scenario.inputs.allowed_contexts; + } + mocks.context.eventName = scenario.eventName; + mocks.context.payload = scenario.payload; + + const returnCode = await run(); + + expect(outputFor('continue')).toBe(scenario.expected.continue); + + if (scenario.expected.command !== undefined) { + expect(outputFor('command')).toBe(scenario.expected.command); + } + + if (scenario.expected.params !== undefined) { + const rawParams = outputFor('params'); + expect(typeof rawParams).toBe('string'); + expect(JSON.parse(rawParams as string)).toEqual(scenario.expected.params); + } + + if (scenario.expected.number !== undefined) { + expect(outputFor('number')).toBe(scenario.expected.number); + } + + if (scenario.expected.issue_number === 'unset') { + expect(outputFor('issue_number')).toBeUndefined(); + } else if (scenario.expected.issue_number !== undefined) { + expect(outputFor('issue_number')).toBe(scenario.expected.issue_number); + } + + if (scenario.expected.context !== undefined) { + expect(outputFor('context')).toBe(scenario.expected.context); + } + + if (scenario.expected.comment_id !== undefined) { + expect(outputFor('comment_id')).toBe(scenario.expected.comment_id); + } + + if (scenario.expected.actor !== undefined) { + expect(outputFor('actor')).toBe(scenario.expected.actor); + } + + if (scenario.expected.failed === true) { + expect(mocks.setFailed).toHaveBeenCalled(); + } else { + expect(mocks.setFailed).not.toHaveBeenCalled(); + } + + if (scenario.expected.warning !== undefined) { + expect(matched(mocks.warning.mock.calls, scenario.expected.warning)).toBe(true); + } + + if (scenario.expected.info !== undefined) { + expect(matched(mocks.info.mock.calls, scenario.expected.info)).toBe(true); + } + + if (scenario.expected.returnCode !== undefined) { + expect(returnCode).toBe(scenario.expected.returnCode); + } +});