// workflow-yaml-state.test.mjs — unit tests for E11 line-based state machine. import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; const { parseWorkflow, extractTriggers } = await import('../../scanners/lib/workflow-yaml-state.mjs'); describe('extractTriggers', () => { it('handles `on: push` (string form)', () => { const t = extractTriggers(['on: push'.split('\n')[0]]); assert.deepEqual([...t], ['push']); }); it('handles `on: [push, pull_request]` (inline list)', () => { const t = extractTriggers(['on: [push, pull_request_target]']); assert.deepEqual([...t].sort(), ['pull_request_target', 'push']); }); it('handles block list', () => { const text = ['on:', ' - push', ' - pull_request']; const t = extractTriggers(text); assert.deepEqual([...t].sort(), ['pull_request', 'push']); }); it('handles block mapping', () => { const text = ['on:', ' pull_request_target:', ' branches: [main]', ' discussion:', 'jobs:']; const t = extractTriggers(text); assert.ok(t.has('pull_request_target')); assert.ok(t.has('discussion')); }); it('returns empty set when no `on:` block found', () => { const t = extractTriggers(['name: hello', 'jobs:', ' build:', ' runs-on: ubuntu-latest']); assert.equal(t.size, 0); }); }); describe('parseWorkflow — single-line run:', () => { it('emits a run-context event for ${{ ... }} in inline run:', () => { const yml = [ 'on: pull_request_target', 'jobs:', ' j:', ' steps:', ' - name: echo', ' run: echo "${{ github.head_ref }}"', ].join('\n'); const { events } = parseWorkflow(yml); const runs = events.filter(e => e.parent === 'run'); assert.equal(runs.length, 1); assert.equal(runs[0].expr, 'github.head_ref'); assert.equal(runs[0].blockScalar, false); }); it('emits an if-context event (parent === "if") for if: expression', () => { const yml = [ 'on: pull_request_target', 'jobs:', ' j:', ' if: ${{ startsWith(github.head_ref, "release/") }}', ' runs-on: ubuntu-latest', ].join('\n'); const { events } = parseWorkflow(yml); const ifs = events.filter(e => e.parent === 'if'); assert.ok(ifs.length >= 1); assert.ok(ifs[0].expr.startsWith('startsWith')); }); }); describe('parseWorkflow — block scalars', () => { it('tracks `run: |` body lines as run-context with blockScalar=true', () => { const yml = [ 'on: pull_request_target', 'jobs:', ' j:', ' steps:', ' - name: multi', ' run: |', ' echo "Issue title:"', ' echo "${{ github.event.issue.body }}"', ' echo done', ].join('\n'); const { events } = parseWorkflow(yml); const runs = events.filter(e => e.parent === 'run'); assert.equal(runs.length, 1); assert.equal(runs[0].expr, 'github.event.issue.body'); assert.equal(runs[0].blockScalar, true); assert.equal(runs[0].line, 8); }); it('tracks `run: >` (folded scalar) the same way', () => { const yml = [ 'on: pull_request', 'jobs:', ' j:', ' steps:', ' - name: folded', ' run: >', ' echo ${{ github.event.pull_request.title }}', ].join('\n'); const { events } = parseWorkflow(yml); assert.ok(events.find(e => e.parent === 'run' && e.blockScalar)); }); }); describe('parseWorkflow — sink-mismatch contexts', () => { it('parent === "env" for top-level env: mapping with ${{ ... }}', () => { const yml = [ 'on: pull_request_target', 'env:', ' PR_TITLE: ${{ github.event.pull_request.title }}', 'jobs:', ' j:', ' runs-on: ubuntu-latest', ].join('\n'); const { events } = parseWorkflow(yml); const envEvts = events.filter(e => e.parent === 'PR_TITLE'); assert.equal(envEvts.length, 1); assert.ok(envEvts[0].parentChain.includes('env')); }); it('parent === "with" for action input', () => { const yml = [ 'on: pull_request', 'jobs:', ' j:', ' steps:', ' - uses: actions/checkout@v4', ' with:', ' ref: ${{ github.head_ref }}', ].join('\n'); const { events } = parseWorkflow(yml); const withEvts = events.filter(e => e.parent === 'ref'); assert.equal(withEvts.length, 1); assert.ok(withEvts[0].parentChain.includes('with')); }); }); describe('parseWorkflow — no-op cases', () => { it('returns empty events for workflow with no expressions', () => { const yml = [ 'on: push', 'jobs:', ' j:', ' runs-on: ubuntu-latest', ' steps:', ' - run: echo hello', ].join('\n'); const { events } = parseWorkflow(yml); assert.equal(events.length, 0); }); it('strips comments before parsing', () => { const yml = [ 'on: push', '# comment ${{ github.head_ref }} should be ignored', 'jobs:', ' j:', ' runs-on: ubuntu-latest', ].join('\n'); const { events } = parseWorkflow(yml); assert.equal(events.length, 0); }); it('handles multiple ${{ ... }} on a single line', () => { const yml = [ 'on: pull_request_target', 'jobs:', ' j:', ' steps:', ' - run: echo "${{ github.head_ref }} and ${{ github.event.pull_request.title }}"', ].join('\n'); const { events } = parseWorkflow(yml); const runs = events.filter(e => e.parent === 'run'); assert.equal(runs.length, 2); }); }); describe('parseWorkflow — line-number accuracy', () => { it('reports correct line for inline run:', () => { const yml = [ 'name: x', 'on: push', '', 'jobs:', ' j:', ' steps:', ' - run: echo "${{ github.head_ref }}"', ].join('\n'); const { events } = parseWorkflow(yml); assert.equal(events[0].line, 7); }); });