/** * data-testid contract — ui-test scenarios and manifest expectations select * ONLY ids the generated pages render (lib/test-ids). Before 2026-09-24 the * templates selected `[data-testid="row-edit"]` while the list rendered * `row-edit-`, and the manifest expected `${section}-list-table` / * `${section}-detail` / `${section}-permission-denied` that nothing rendered: * 5 scenarios out of 6 failed by construction (audit D-CRIT-04). * * Residue closed 2026-09-24 (QC D8): the detail scenario selected * `[data-testid$="-list-row"] a` — the DataTable row is a `` navigated * through onRowClick, no generated row carries a link, so the scenario always * reported "list is empty" on a populated list. The contract now parses EVERY * selector of every template (descendant parts and attribute forms included) * and resolves each compound structurally against the REAL rendered output: * the pages scaffold-component emits, the primitives scaffold-ui-primitives * emits (the row lives there, not in the page), the PermissionGuard * scaffold-frontend-auth emits — see ./selector-contract.ts. */ import { describe, it, expect, afterAll } from 'vitest' import { mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { dirname, join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import { TEST_IDS } from '../../../../../../lib/test-ids.js' import { generate as generatePages } from '../../../../../frontend/component/cli/scaffold-component/generate.js' import { validate as validatePages } from '../../../../../frontend/component/cli/scaffold-component/validate.js' import { generate as generatePrimitives } from '../../../../../frontend/ui-primitives/cli/scaffold-ui-primitives/generate.js' import { validate as validatePrimitives } from '../../../../../frontend/ui-primitives/cli/scaffold-ui-primitives/validate.js' import { generate as generateAuth } from '../../../../../frontend/auth/cli/scaffold-frontend-auth/generate.js' import { validate as validateAuth } from '../../../../../frontend/auth/cli/scaffold-frontend-auth/validate.js' import { generate as generateManifest } from '../../build-manifest/generate.js' import { validate as validateManifest } from '../../build-manifest/validate.js' import { extractSelectors, parseSelector, resolveSelector, type RenderedSource, type TemplateSelector } from './selector-contract.js' const here = dirname(fileURLToPath(import.meta.url)) const TEMPLATES = resolve(here, '../templates') /** Non-trivial plural: `JourFerie` → section `jours-feries`, plural component `JourFeries`. */ const ENTITY = 'JourFerie' const SECTION = 'jours-feries' const MODULE = 'hrm' /** * Ids the contract owns but no scaffolder of THIS repo renders: the login form is * package-owned (@atlashub/smartstack LoginPage); the delete-confirm affordance is * optional (the delete scenario tolerates its absence). Accepted as a single * compound only — nothing can be descended into. */ const CONTRACT_ONLY_IDS = [TEST_IDS.loginEmail, TEST_IDS.loginPassword, TEST_IDS.loginSubmit, TEST_IDS.confirmDelete] as const const tmp = mkdtempSync(join(tmpdir(), 'ss-testid-contract-')) afterAll(() => rmSync(tmp, { recursive: true, force: true })) function pages(): Record<'list' | 'detail' | 'form', string> { const v = validatePages({ module: MODULE, appCode: 'myapp', entity: ENTITY, section: SECTION, views: ['list', 'detail', 'form'], fields: [{ name: 'label', type: 'string' }, { name: 'date', type: 'date' }], projectPath: '/web', }) expect(v.valid, v.errors.join('\n')).toBe(true) const files = generatePages(v.data!) const find = (re: RegExp) => files.find((f) => re.test(f.path))?.content ?? '' return { list: find(/ListPage\.tsx$/), detail: find(/DetailPage\.tsx$/), form: find(/FormPage\.tsx$/) } } /** Every primitive scaffold-ui-primitives emits (DataTable, SectionCard, RowActionsMenu, …), as rendered. */ function primitives(): RenderedSource[] { const v = validatePrimitives({ projectPath: tmp, appCode: 'myapp' }) expect(v.valid, v.errors.join('\n')).toBe(true) return generatePrimitives(v.spec!) .filter((f) => f.path.endsWith('.tsx')) .map((f) => ({ name: f.path.replace(/^.*\//, ''), content: f.content })) } function permissionGuard(): RenderedSource { const v = validateAuth({ projectPath: tmp, appCode: 'myapp' }) expect(v.valid, v.errors.join('\n')).toBe(true) const file = generateAuth(v.spec!).find((f) => /PermissionGuard\.tsx$/.test(f.path)) expect(file, 'scaffold-frontend-auth emits PermissionGuard.tsx').toBeDefined() return { name: 'PermissionGuard.tsx', content: file!.content } } const p = pages() const prim = primitives() const guard = permissionGuard() const dataTable = prim.find((s) => s.name === 'DataTable.tsx')! const SOURCES: RenderedSource[] = [ { name: `${ENTITY}sListPage.tsx`, content: p.list }, { name: `${ENTITY}DetailPage.tsx`, content: p.detail }, { name: `${ENTITY}FormPage.tsx`, content: p.form }, ...prim, guard, ] const TEMPLATE_FILES = readdirSync(TEMPLATES).filter((f) => f.endsWith('.hbs')).sort() const templateSource = (name: string): string => readFileSync(join(TEMPLATES, name), 'utf-8') const ALL_SELECTORS: TemplateSelector[] = TEMPLATE_FILES.flatMap((name) => extractSelectors(name, templateSource(name))) const STATIC_SELECTORS = ALL_SELECTORS.filter((s) => !s.dynamic) const resolveIn = (selector: string) => resolveSelector(selector, SOURCES, { contractOnlyIds: CONTRACT_ONLY_IDS }) describe('rendered ids (scaffold-component / ui-primitives / auth)', () => { it('the list page anchors its host with listTable(section) and its row affordances with the prefixes', () => { expect(p.list).toContain(`data-testid="${TEST_IDS.listTable(SECTION)}"`) expect(p.list).toContain('data-testid={`' + TEST_IDS.rowEditPrefix + '${item.id}`}') expect(p.list).toContain('data-testid={`' + TEST_IDS.rowDeletePrefix + '${item.id}`}') }) it('the detail page anchors detailRoot(section); the form page its submit + fields', () => { expect(p.detail).toContain(`data-testid="${TEST_IDS.detailRoot(SECTION)}"`) expect(p.form).toContain(`data-testid="${TEST_IDS.formSubmit}"`) expect(p.form).toContain(`data-testid="${TEST_IDS.formField('label')}"`) }) it('the rendered DataTable renders listRow on every data row; the rendered PermissionGuard renders permissionDenied', () => { expect(dataTable.content).toContain(`data-testid="${TEST_IDS.listRow}"`) expect(guard.content).toContain(`data-testid="${TEST_IDS.permissionDenied}"`) }) it('a generated list row is a navigated through onRowClick — no //href in the row, the page or the table (the D8 premise)', () => { expect(p.list).not.toMatch(/]|]|href=/) expect(dataTable.content).not.toMatch(/]|]|href=/) expect(p.list).toContain('onRowClick={(item) => navigate(') }) }) describe('selector-contract parser (unit)', () => { it('splits a descendant selector into compounds and reads the attribute operators', () => { expect(parseSelector('[data-testid$="-list-row"] a')).toEqual([ { raw: '[data-testid$="-list-row"]', tag: undefined, attrs: [{ name: 'data-testid', op: '$=', value: '-list-row' }] }, { raw: 'a', tag: 'a', attrs: [] }, ]) expect(parseSelector('tr[data-testid="ss-list-row"] > [data-testid^="row-edit-"]')).toEqual([ { raw: 'tr[data-testid="ss-list-row"]', tag: 'tr', attrs: [{ name: 'data-testid', op: '=', value: 'ss-list-row' }] }, { raw: '[data-testid^="row-edit-"]', tag: undefined, attrs: [{ name: 'data-testid', op: '^=', value: 'row-edit-' }] }, ]) }) it('reduces a JS-concatenated selector to a wildcard and flags a Handlebars one as dynamic', () => { const src = [ `const selector = '[data-testid="form-field-' + key + '"]';`, `const loc = page.locator(selector);`, `await page.locator('[data-testid="{{expectations.rendersTestId}}"]').count();`, `page.locator('[data-testid="ss-list-row"] [data-testid^="row-delete-"]').first()`, ].join('\n') expect(extractSelectors('x.hbs', src)).toEqual([ { file: 'x.hbs', selector: '[data-testid="form-field-*"]', dynamic: false }, { file: 'x.hbs', selector: '[data-testid="{{expectations.rendersTestId}}"]', dynamic: true }, { file: 'x.hbs', selector: '[data-testid="ss-list-row"] [data-testid^="row-delete-"]', dynamic: false }, ]) }) it('refuses selector syntax it cannot check (a gate that cannot fail lets the defect ship)', () => { expect(() => parseSelector('td:not(:has(input))')).toThrow(/unsupported selector syntax/) expect(() => extractSelectors('x.hbs', `page.locator(buildSelector(key) + '"]')`)).toThrow(/unsupported selector expression/) }) }) describe('selector-contract resolution against the rendered output (FIRES)', () => { it('the pre-fix detail selector `[data-testid$="-list-row"] a` is UNRESOLVED: the row resolves in DataTable.tsx, the `a` nowhere inside it', () => { const r = resolveIn('[data-testid$="-list-row"] a') expect(r.resolved).toBe(false) expect(r.unresolved).toEqual(['a']) // The row subtree carries the cell slot, so the page's columns[].render joins the scope — and still no . expect(r.resolvedIn).toEqual([`[data-testid$="-list-row"] → DataTable.tsx, ${ENTITY}sListPage.tsx#columns[].render`]) }) it('the D-CRIT-04 selectors stay unresolved: bare `row-edit`, a section-prefixed row, a section-prefixed denied id', () => { expect(resolveIn('[data-testid="row-edit"]').unresolved).toEqual(['[data-testid="row-edit"]']) expect(resolveIn('[data-testid="row-edit-"]').resolved).toBe(false) expect(resolveIn(`[data-testid="${SECTION}-list-row"]`).resolved).toBe(false) expect(resolveIn(`[data-testid="${SECTION}-permission-denied"]`).resolved).toBe(false) }) it('a prefix/suffix selector is satisfied by the LITERAL part of a rendered id only — never by what an interpolation might produce', () => { // `row-edit-${item.id}` / `section-edit-${sectionKey}` end in an interpolation: a suffix nothing literally renders stays unresolved. expect(resolveIn('[data-testid$="-nonsense"]').resolved).toBe(false) // A prefix that runs INTO the interpolation is a bet on data, not a contract on structure. expect(resolveIn(`[data-testid^="${TEST_IDS.rowEditPrefix}abc"]`).resolved).toBe(false) // While the literal parts do resolve. expect(resolveIn(`[data-testid^="${TEST_IDS.rowEditPrefix}"]`).resolved).toBe(true) expect(resolveIn(`[data-testid$="${TEST_IDS.listRow.slice(2)}"]`).resolvedIn).toEqual([ `[data-testid$="${TEST_IDS.listRow.slice(2)}"] → DataTable.tsx, ${ENTITY}sListPage.tsx#columns[].render`, ]) }) it('scoping is structural: the skeleton row carries no cell slot, so a row affordance under it is unresolved', () => { const r = resolveIn(`[data-testid="${TEST_IDS.listSkeletonRow}"] [data-testid^="${TEST_IDS.rowEditPrefix}"]`) expect(r.resolved).toBe(false) expect(r.unresolved).toEqual([`[data-testid^="${TEST_IDS.rowEditPrefix}"]`]) }) it('a contract-only id (login form) cannot be descended into', () => { expect(resolveIn(`[data-testid="${TEST_IDS.loginSubmit}"]`).resolved).toBe(true) const r = resolveIn(`[data-testid="${TEST_IDS.loginSubmit}"] span`) expect(r.resolved).toBe(false) expect(r.unresolved).toEqual(['span']) }) }) describe('selector-contract resolution against the rendered output (SILENT)', () => { it('a row affordance resolves THROUGH the DataTable row INTO the page column renders', () => { const r = resolveIn(`[data-testid="${TEST_IDS.listRow}"] [data-testid^="${TEST_IDS.rowDeletePrefix}"]`) expect(r.resolved, r.unresolved.join(' ')).toBe(true) expect(r.resolvedIn[0]).toContain('DataTable.tsx') expect(r.resolvedIn[1]).toContain(`${ENTITY}sListPage.tsx#columns[].render`) }) it('the read-first section toggle resolves against the rendered SectionCard (ternary of two template literals)', () => { const r = resolveIn(`[data-testid^="${TEST_IDS.sectionEditPrefix}"]`) expect(r.resolved).toBe(true) expect(r.resolvedIn[0]).toContain('SectionCard.tsx') }) }) describe('run-ui-test scenario templates select rendered ids only', () => { it('finds the selectors (sanity) — six templates, every one selecting something', () => { expect(TEMPLATE_FILES).toEqual(['delete.js.hbs', 'detail.js.hbs', 'edit.js.hbs', 'form-submit.js.hbs', 'list.js.hbs', 'permission-negative.js.hbs']) expect(STATIC_SELECTORS.length).toBeGreaterThan(8) for (const name of TEMPLATE_FILES) expect(ALL_SELECTORS.some((s) => s.file === name), `${name} hands no selector to page.locator`).toBe(true) }) it('EVERY static selector of EVERY template resolves compound by compound against the rendered pages + primitives', () => { const failures: string[] = [] for (const s of STATIC_SELECTORS) { const r = resolveIn(s.selector) if (!r.resolved) failures.push(`${s.file}: ${s.selector} — nothing renders ${r.unresolved.join(' ')} (resolved so far: ${r.resolvedIn.join(' | ') || 'nothing'})`) } expect(failures, failures.join('\n')).toEqual([]) }) it('the detail scenario clicks the DataTable row itself (exact listRow id) — no link is selected inside a row', () => { const detail = STATIC_SELECTORS.filter((s) => s.file === 'detail.js.hbs').map((s) => s.selector) expect(detail).toContain(`[data-testid="${TEST_IDS.listRow}"]`) for (const s of ALL_SELECTORS) { expect(parseSelector(s.selector).some((c) => c.tag === 'a'), `${s.file}: ${s.selector} descends into an no row renders`).toBe(false) } }) it('the edit and delete scenarios descend from the exact listRow id into the row-edit-/row-delete- prefixes', () => { const edit = STATIC_SELECTORS.filter((s) => s.file === 'edit.js.hbs').map((s) => s.selector) const del = STATIC_SELECTORS.filter((s) => s.file === 'delete.js.hbs').map((s) => s.selector) expect(edit).toContain(`[data-testid="${TEST_IDS.listRow}"] [data-testid^="${TEST_IDS.rowEditPrefix}"]`) expect(del).toContain(`[data-testid="${TEST_IDS.listRow}"] [data-testid^="${TEST_IDS.rowDeletePrefix}"]`) }) it('every exact selector names a rendered id; every prefix/suffix selector uses a rendered prefix/suffix', () => { const exact = new Set([...CONTRACT_ONLY_IDS, TEST_IDS.listRow, TEST_IDS.formSubmit]) for (const s of STATIC_SELECTORS) { for (const c of parseSelector(s.selector)) { for (const a of c.attrs.filter((x) => x.name === 'data-testid')) { if (a.op === '=' && !a.value.includes('*')) expect(exact.has(a.value), `${s.file}: [data-testid="${a.value}"] is not a rendered id`).toBe(true) if (a.op === '=' && a.value.includes('*')) expect(a.value, `${s.file}: concatenated ${a.value}`).toBe(`${TEST_IDS.formField('')}*`) if (a.op === '^=') expect([TEST_IDS.rowEditPrefix, TEST_IDS.rowDeletePrefix, TEST_IDS.sectionEditPrefix], `${s.file}: prefix ${a.value}`).toContain(a.value) if (a.op === '$=') expect(TEST_IDS.listRow.endsWith(a.value), `${s.file}: suffix ${a.value} does not match ${TEST_IDS.listRow}`).toBe(true) } } } }) it('the edit and delete scenarios never select the bare `row-edit` / `row-delete`', () => { for (const s of ALL_SELECTORS) { for (const c of parseSelector(s.selector)) { for (const a of c.attrs) expect(a.value === 'row-edit' || a.value === 'row-delete', `${s.file} selects bare ${a.value}`).toBe(false) } } }) }) describe('manifest expectations feed the dynamic selectors with rendered ids', () => { const v = validateManifest({ module: MODULE, appCode: 'myapp', projectPath: tmp, entities: [{ name: ENTITY, section: SECTION, plural: SECTION, rolesWithRead: ['admin', 'viewer'], rolesWithCreate: ['admin'], rolesWithUpdate: ['admin'], rolesWithDelete: ['admin'], rolesWithoutAccess: ['guest'], fixture: { label: 'Ascension' }, }], }) expect(v.valid, v.errors.join('\n')).toBe(true) const manifest = JSON.parse(generateManifest(v.data!)[0].content) as { tests: Array<{ id: string; scenario: string; expectations: { rendersTestId?: string } }> } it('covers the three scenarios that render an expected id (sanity)', () => { expect(new Set(manifest.tests.filter((t) => t.expectations.rendersTestId).map((t) => t.scenario))).toEqual(new Set(['list', 'detail', 'permission-negative'])) }) it('every `{{expectations.rendersTestId}}` selector, fed by the manifest of that scenario, resolves against the rendered output', () => { const failures: string[] = [] for (const t of manifest.tests) { const dynamic = ALL_SELECTORS.filter((s) => s.file === `${t.scenario}.js.hbs` && s.dynamic) for (const s of dynamic) { expect(s.selector, `${s.file}: unknown dynamic selector ${s.selector}`).toContain('{{expectations.rendersTestId}}') if (!t.expectations.rendersTestId) { failures.push(`${t.id}: ${s.file} selects {{expectations.rendersTestId}} but the manifest gives none`); continue } const r = resolveIn(s.selector.replace('{{expectations.rendersTestId}}', t.expectations.rendersTestId)) if (!r.resolved) failures.push(`${t.id}: ${r.selector} — nothing renders ${r.unresolved.join(' ')}`) } } expect(failures, failures.join('\n')).toEqual([]) }) })