// tests/lib/test-first-multi-file.test.mjs // Plan step 3 (files-backticks trial 4): below plan_version 1.9 every test file // on the `Test first:` `File:` line is read, not only the first backticked // span. 6.3.1 read the first span alone, so a step whose `Files:` were its two // `Test first:` files was not seen as tests-only when the second file is not a // test by its path. One reading: testFirstFiles (plan-schema.mjs), used by // stepTestsOnly; the executor and the step helper say the same in prose. import { test } from 'node:test'; import { strict as assert } from 'node:assert'; import { readFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import * as m from '../../lib/parsers/plan-schema.mjs'; import { validatePlanContent } from '../../lib/validators/plan-validator.mjs'; import { isTestPath } from '../../lib/util/test-path.mjs'; import { buildPlan } from '../helpers/plan-v18-builder.mjs'; const HERE = dirname(fileURLToPath(import.meta.url)); const ROOT = join(HERE, '..', '..'); /** A 1.8-shaped step section: `Files:` and the `Test first:` lines given. */ const step = (files, testFirst) => [ '### Step 1: x', '', `- **Files:** ${files}`, '- **Changes:** x', '- **Test first:**', ...testFirst, '- **Verify:** `node --test tests/a.test.mjs` -> expected: exit 0', ].join('\n'); const TWO = step('`tests/a.test.mjs`, `checks/smoke.mjs`', [ ' - File: `tests/a.test.mjs`, `checks/smoke.mjs` (new)', ' - Verifies: both checks run', ]); const testFirstFiles = (body, version) => { assert.equal(typeof m.testFirstFiles, 'function', 'plan-schema.mjs exports testFirstFiles'); return m.testFirstFiles(m.parseStepFields(body), version); }; test('a 1.8 step whose Files are its two Test first files is tests-only', () => { // checks/smoke.mjs is not a test by its path: only the Test first line makes it one. assert.equal(m.stepTestsOnly(TWO).testsOnly, true, JSON.stringify(m.stepTestsOnly(TWO))); }); test('testFirstFiles returns every path on the File: line, below 1.9 and without a version', () => { assert.deepEqual(testFirstFiles(TWO, '1.8'), ['tests/a.test.mjs', 'checks/smoke.mjs']); assert.deepEqual(testFirstFiles(TWO, null), ['tests/a.test.mjs', 'checks/smoke.mjs']); assert.deepEqual(testFirstFiles(TWO), ['tests/a.test.mjs', 'checks/smoke.mjs']); }); test('testFirstFiles: a call, an option or a URL on the File: line is not read; paths are normalised and deduplicated', () => { const body = step('`tests/a.test.mjs`', [ ' - File: `tests/a.test.mjs`, `parse()`, `--watch`, `https://example.invalid/x.mjs`, `./tests/a.test.mjs`', ' - Verifies: x', ]); assert.deepEqual(testFirstFiles(body, '1.8'), ['tests/a.test.mjs']); }); test('testFirstFiles: a Verifies: line, or a span before File: on the line, is never read', () => { const body = step('`tests/a.test.mjs`', [ ' - see `lib/before.mjs` File: `tests/a.test.mjs`', ' - Verifies: `lib/other.mjs` returns `src/x.mjs`', ]); assert.deepEqual(testFirstFiles(body, '1.8'), ['tests/a.test.mjs']); const none = step('`tests/a.test.mjs`', [' - Verifies: `tests/a.test.mjs` runs']); assert.deepEqual(testFirstFiles(none, '1.8'), []); }); test('a code file beside the test on the File: line still makes the step not tests-only when Files has more', () => { const body = step('`tests/a.test.mjs`, `src/cli.mjs`', [ ' - File: `tests/a.test.mjs`, `parse()` (new)', ' - Verifies: x', ]); assert.equal(m.stepTestsOnly(body).testsOnly, false); }); // ---- plan_version 1.9 (plan step 5) ---------------------------------------- // From 1.9 a `Test first:` `File:` line is every line that opens with `File:` // (after an optional list marker and `**`), backticked or not, and its rest is // one entry read by entryPaths: the leading backticked paths are the test // files, the rest is description. The validator puts the same grammar on it. const PATH_CODES = ['PLAN_STEP_PATH_NOT_FIRST', 'PLAN_STEP_PATH_NOT_A_PATH']; // A plan of the given version whose first step has `Files:` `files` and the // `Test first:` `File:` line `fileLine` (both verbatim). const planWith = (version, files, fileLine) => buildPlan({ version, steps: [{ filesRaw: files, testFirstRaw: fileLine }, { endToEnd: 'bash tests/e2e.sh' }], }); test('1.9: every leading backticked path on the File: line is a test file', () => { const body = step('`tests/a.test.mjs`, `tests/b.test.mjs`', [ ' - File: `tests/a.test.mjs`, `tests/b.test.mjs` (new)', ' - Verifies: x', ]); assert.deepEqual(testFirstFiles(body, '1.9'), ['tests/a.test.mjs', 'tests/b.test.mjs']); }); test('decision 20: a backticked path in the description of the File: line is not a test file, at 1.9 and below', () => { const body = step('`tests/a.test.mjs`', [ ' - File: `tests/a.test.mjs` (new), covers `tests/b.test.mjs`', ' - Verifies: x', ]); assert.deepEqual(testFirstFiles(body, '1.9'), ['tests/a.test.mjs']); // Operator decision 20 (12.10): below 1.9 too, only the paths right after File:. assert.deepEqual(testFirstFiles(body, '1.8'), ['tests/a.test.mjs']); }); test('decision 20: a code file named only in the File: line description keeps the step from being tests-only, at 1.9 and 1.8', () => { const files = '`tests/a.test.mjs`, `checks/smoke.mjs`'; const line = ' - File: `tests/a.test.mjs` (new), covers `checks/smoke.mjs`'; const body = step(files, [line, ' - Verifies: x']); assert.equal(m.stepTestsOnly(body, '1.9').testsOnly, false, JSON.stringify(m.stepTestsOnly(body, '1.9'))); assert.equal(m.stepTestsOnly(body, '1.8').testsOnly, false, JSON.stringify(m.stepTestsOnly(body, '1.8'))); const v19 = validatePlanContent(planWith('1.9', files, line)); assert.ok(!v19.warnings.some((w) => w.code === 'PLAN_STEP_TESTS_ONLY'), JSON.stringify(v19.warnings)); assert.deepEqual(v19.errors.filter((e) => PATH_CODES.includes(e.code)), [], JSON.stringify(v19.errors)); const v18 = validatePlanContent(planWith('1.8', files, line)); assert.ok(!v18.warnings.some((w) => w.code === 'PLAN_STEP_TESTS_ONLY'), JSON.stringify(v18.warnings)); }); test('1.9: a File: line that opens with `parse()` is refused as no path (error strict, warning soft)', () => { const text = planWith('1.9', '`tests/a.test.mjs`', ' - File: `parse()`'); const strict = validatePlanContent(text); const e = strict.errors.find((i) => i.code === 'PLAN_STEP_PATH_NOT_A_PATH'); assert.ok(e, JSON.stringify(strict.errors)); assert.match(e.message, /Step 1/); assert.match(e.message, /Test first/); const soft = validatePlanContent(text, { strict: false }); assert.ok(soft.warnings.some((w) => w.code === 'PLAN_STEP_PATH_NOT_A_PATH'), JSON.stringify(soft.warnings)); assert.deepEqual(soft.errors.filter((i) => PATH_CODES.includes(i.code)), []); }); test('1.9: an unticked File: line gets PLAN_STEP_PATH_NOT_FIRST next to PLAN_STEP_NO_TEST_FIRST', () => { const r = validatePlanContent(planWith('1.9', '`tests/a.test.mjs`', ' - File: tests/a.test.mjs')); const codes = r.errors.map((e) => e.code); assert.ok(codes.includes('PLAN_STEP_PATH_NOT_FIRST'), codes.join(',')); assert.ok(codes.includes('PLAN_STEP_NO_TEST_FIRST'), codes.join(',')); const e = r.errors.find((i) => i.code === 'PLAN_STEP_PATH_NOT_FIRST'); assert.match(e.message, /Step 1/); assert.match(e.message, /tests\/a\.test\.mjs/); }); // ---- operator decision 20 (12.10), review 6c31212c -------------------------- // Below 1.9 too, a step's test files are the backticked paths that stand right // after `File:`, separated only by commas or whitespace. A path in the // description after them (`covers parse() in src/parser.mjs`) is no test file, // so a step that builds that source is not tests-only and its source is never // locked. Each `File:` marker on a line starts its own run, as 6.3.1 read the // first span after each marker. // The review's exact input: the description names a call and the source. const REVIEW_FILES = '`src/parser.mjs`, `tests/parser.test.mjs`'; const REVIEW_LINE = ' - File: `tests/parser.test.mjs` (new) \u2014 covers `parse()` in `src/parser.mjs`'; test('decision 20: the review input (6c31212c) gives only the test below 1.9, is not tests-only, and gets no PLAN_STEP_TESTS_ONLY', () => { const body = step(REVIEW_FILES, [REVIEW_LINE, ' - Verifies: x']); for (const v of ['1.8', '1.7', null, undefined]) { assert.deepEqual(testFirstFiles(body, v), ['tests/parser.test.mjs'], `version ${v}`); } assert.equal(m.stepTestsOnly(body, '1.8').testsOnly, false, JSON.stringify(m.stepTestsOnly(body, '1.8'))); assert.equal(m.stepTestsOnly(body).testsOnly, false, JSON.stringify(m.stepTestsOnly(body))); const v18 = validatePlanContent(planWith('1.8', REVIEW_FILES, REVIEW_LINE)); assert.ok(!v18.warnings.some((w) => w.code === 'PLAN_STEP_TESTS_ONLY'), JSON.stringify(v18.warnings)); }); test('decision 20: two leading tests (comma or space) before a description that names a source give the two tests at 1.8 and 1.9', () => { for (const lead of ['`tests/a.test.mjs`, `tests/b.test.mjs`', '`tests/a.test.mjs` `tests/b.test.mjs`']) { const body = step('`tests/a.test.mjs`, `tests/b.test.mjs`, `src/a.mjs`', [ ` - File: ${lead} (new) - covers \`run()\` in \`src/a.mjs\``, ' - Verifies: x', ]); for (const v of ['1.8', '1.9']) { assert.deepEqual(testFirstFiles(body, v), ['tests/a.test.mjs', 'tests/b.test.mjs'], `${v}: ${lead}`); } } }); test('decision 20: a 1.8 line with two File: markers, each followed by a test, gives both tests (6.3.1 read both)', () => { const body = step('`tests/a.test.mjs`, `tests/b.test.mjs`', [ ' - File: `tests/a.test.mjs` (new) and File: `tests/b.test.mjs` (existing)', ' - Verifies: x', ]); assert.deepEqual(testFirstFiles(body, '1.8'), ['tests/a.test.mjs', 'tests/b.test.mjs']); assert.deepEqual(testFirstFiles(body, null), ['tests/a.test.mjs', 'tests/b.test.mjs']); }); // ---- operator decisions 28 (13.10) and 31 (14.10), review bd2520b0 --------- // Below 1.9 the run after `File:` also goes on past a note in brackets // (`(new)`, `*(new)*`, `(existing ...)`) and the whole words `and`, `plus` and // `+`, to a further path only when it is clearly a test; a path that is not // ends the run, and so does any other text. At 1.9 nothing changes. const OLDER = ['1.8', '1.7', null]; const fileLine = (rest) => step('`tests/a.test.mjs`, `tests/b.test.mjs`', [` - File: ${rest}`, ' - Verifies: x']); test('decisions 28 and 31: below 1.9 a test after a note, and, plus or + is read', () => { const lines = [ '`tests/a.test.mjs` (new), `tests/b.test.mjs` (new),', '`tests/a.test.mjs` and `tests/b.test.mjs`', '`tests/a.test.mjs` (existing) + `tests/b.test.mjs`', '`tests/a.test.mjs` (existing, the parser) plus `tests/b.test.mjs`', '`tests/a.test.mjs` *(new)*, `tests/b.test.mjs`', ]; for (const rest of lines) { for (const v of OLDER) { assert.deepEqual(testFirstFiles(fileLine(rest), v), ['tests/a.test.mjs', 'tests/b.test.mjs'], `version ${v}: ${rest}`); } } }); test('decisions 28 and 31: below 1.9 a near miss gives only the first test', () => { // Both read as tests by lib/util/test-path.mjs alone (a specs/ folder); the // run refuses them all the same: a document or a data file outside tests/. assert.equal(isTestPath('docs/specs/api.md'), true); assert.equal(isTestPath('api/specs/openapi.yaml'), true); const lines = [ '`tests/a.test.mjs` (new) - covers `src/a.mjs`', '`tests/a.test.mjs` (new), `src/a.mjs`', '`tests/a.test.mjs` (new) or `tests/b.test.mjs`', '`tests/a.test.mjs` (new), covers `tests/b.test.mjs`', '`tests/a.test.mjs` (new) and `src/a.mjs`', '`tests/a.test.mjs` (new) and `https://example.invalid/tests/b.test.mjs`', '`tests/a.test.mjs` (new) and `docs/specs/api.md`', '`tests/a.test.mjs` (new), `api/specs/openapi.yaml`', '`tests/a.test.mjs` android `tests/b.test.mjs`', '`tests/a.test.mjs` andand `tests/b.test.mjs`', '`tests/a.test.mjs` plusand `tests/b.test.mjs`', ]; for (const rest of lines) { for (const v of OLDER) { assert.deepEqual(testFirstFiles(fileLine(rest), v), ['tests/a.test.mjs'], `version ${v}: ${rest}`); } } }); test('decisions 28 and 31: below 1.9 the run after a note stops at the first source path', () => { const rest = '`tests/a.test.mjs` (new), `tests/b.test.mjs`, `src/a.mjs`, `tests/c.test.mjs`'; for (const v of OLDER) { assert.deepEqual(testFirstFiles(fileLine(rest), v), ['tests/a.test.mjs', 'tests/b.test.mjs'], `version ${v}`); } }); test('decisions 28 and 31: at 1.9 the two-test line with notes still gives only the first test', () => { const body = fileLine('`tests/a.test.mjs` (new), `tests/b.test.mjs` (new),'); assert.deepEqual(testFirstFiles(body, '1.9'), ['tests/a.test.mjs']); }); // ---- one definition in the prose (plan step 11, review cf241526) ----------- // The executor and the step helper define the step's test files once, as // decision 20 reads them; every other place points at that definition. Red // proof refuses a --tests that differs from the plan (`tests-mismatch`) and // prints the plan's list (`plan_tests`): the executor and the helper run it // once more with that list, and the lock follows the list red proof accepted. const EXEC = ['commands', 'trekexecute.md']; const HELPER = ['agents', 'step-implementer.md']; const readRel = (rel) => readFileSync(join(ROOT, ...rel), 'utf8'); const count = (text, s) => text.split(s).length - 1; test('prose: neither file reads every backticked span on the File: line any more', () => { for (const rel of [EXEC, HELPER]) { const text = readRel(rel); assert.equal(count(text, 'every backticked span on that line'), 0, `${rel.join('/')} still says "every backticked span on that line"`); } }); test('prose: the decision-20 definition stands exactly once in each file and keeps the pinned phrase', () => { for (const rel of [EXEC, HELPER]) { const text = readRel(rel); assert.equal((text.match(/operator decision 20(?!\d)/g) || []).length, 1, `${rel.join('/')} holds the decision-20 definition once`); assert.ok(text.includes('every file on the `Test first:` `File:` line'), `${rel.join('/')} keeps the pinned phrase`); } }); test('prose: the executor definition names tests-mismatch and plan_tests and keeps its line shape', () => { const lines = readRel(EXEC).split('\n'); const i = lines.findIndex((l) => l.startsWith("**The step's test files** are")); assert.ok(i >= 0, 'the definition line is there'); const def = lines[i]; assert.ok(def.includes('operator decision 20'), 'the definition line carries decision 20'); assert.ok(def.includes('`tests-mismatch`'), 'the definition names tests-mismatch'); assert.ok(def.includes('`plan_tests`'), 'the definition names plan_tests'); assert.ok(def.includes('`--files` is the list red proof accepted'), 'the definition says the lock follows the accepted list'); assert.ok(def.endsWith(' The'), `the definition line still ends with " The": ${def.slice(-40)}`); assert.ok(lines[i + 1].startsWith('plan template lists them'), `the next line: ${lines[i + 1]}`); }); test('prose: the executor exit-2 row names tests-mismatch and the rerun with plan_tests', () => { const row = readRel(EXEC).split('\n').find((l) => l.startsWith('| 2 | `"not-run"`')); assert.ok(row, 'the exit-2 row is there'); assert.ok(row.includes('`tests-mismatch`'), 'the exit-2 row names tests-mismatch'); assert.ok(row.includes('`plan_tests`'), 'the exit-2 row names plan_tests'); }); test('prose: the helper reruns once with plan_tests on tests-mismatch, before the 1.8 branches, and locks the accepted list', () => { const text = readRel(HELPER); const mm = text.match(/tests-mismatch[^.]*once\s+more[^.]*plan_tests/); assert.ok(mm, 'the helper runs the gate once more with plan_tests on tests-mismatch'); assert.match(text, /whatever\s+the\s+plan_version/); const below = text.indexOf('Below 1.8'); assert.ok(below > 0, 'the helper still has its Below 1.8 branch'); assert.ok(mm.index < below, 'the mismatch sentence stands before "Below 1.8"'); assert.match(text, /`--files` is the list red proof accepted/); });