// Wiring assertions for the non-contrast a11y gate (2026-08-03).
//
// The gate itself needs chromium plus a dev server, so it cannot run here. What
// CAN be asserted cheaply — and what the INFRA-F87 precedent says to assert — is
// that the gate is actually HUNG somewhere. A gate nobody invokes is a gate that
// does not exist, and that failure mode is invisible: everything stays green.
//
// It also pins the two properties that would quietly hollow the gate out if
// edited: the exclusion staying a single named rule, and `test:a11y` keeping its
// unconditional assertion (this gate is additive — the moment someone "fixes" the
// red suite by copying the exclusion into it, the Owner's 2026-05-18 no-allowlist
// decision has been overturned by an AI, which is exactly what must not happen).

import { describe, it, expect } from 'vitest'
import { readFileSync, existsSync } from 'node:fs'
import { resolve, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'

const REPO = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const read = (p: string) => readFileSync(resolve(REPO, p), 'utf8')

const SPEC = 'tests/a11y-non-contrast/docs-pages.spec.ts'
const CONFIG = 'playwright.a11y-noncontrast.config.ts'
const SCRIPT = 'audit:a11y-non-contrast'

describe('non-contrast a11y gate — the pieces exist', () => {
  it('has a spec and a dedicated Playwright config', () => {
    expect(existsSync(resolve(REPO, SPEC)), `${SPEC} missing`).toBe(true)
    expect(existsSync(resolve(REPO, CONFIG)), `${CONFIG} missing`).toBe(true)
  })

  it('the config points at the gate directory, not the red a11y suite', () => {
    const cfg = read(CONFIG)
    expect(cfg).toContain(`testDir: 'tests/a11y-non-contrast'`)
    // Sharing testDir with tests/a11y would drag the permanently-red suite in and
    // this gate could never be green — which is its entire reason to exist.
    expect(cfg).not.toContain(`testDir: 'tests/a11y'`)
  })

  it('the single-test sweep has a timeout well above the ~2.5 min it needs', async () => {
    // Playwright's 30s default kills the sweep partway; the first draft hit this.
    // Read the resolved config object rather than grepping: the file holds two
    // `timeout:` keys (this one and webServer's) and the first draft of THIS test
    // matched the wrong one — a regex that reads a plausible neighbouring value is
    // worse than no assertion, because it passes for the wrong reason.
    const cfg = (await import('../playwright.a11y-noncontrast.config')).default as {
      timeout?: number
      webServer?: { timeout?: number }
    }
    expect(cfg.timeout, 'no top-level timeout — the 30s default truncates the sweep').toBeTypeOf(
      'number',
    )
    expect(cfg.timeout!).toBeGreaterThanOrEqual(300_000)
    expect(cfg.timeout, 'read webServer.timeout by mistake').not.toBe(cfg.webServer?.timeout)
  })
})

describe('non-contrast a11y gate — it is actually hung', () => {
  it('package.json exposes the script', () => {
    const pkg = JSON.parse(read('package.json'))
    expect(pkg.scripts[SCRIPT], `package.json is missing the ${SCRIPT} script`).toContain(CONFIG)
  })

  it('release.mjs invokes it as a blocking step', () => {
    const rel = read('scripts/release.mjs')
    expect(rel, `release.mjs never calls ${SCRIPT} — the gate would run nowhere`).toContain(
      `pnpm run ${SCRIPT}`,
    )
    // Blocking, not advisory: the call must be inside a status check that fails.
    expect(rel).toMatch(new RegExp(`if \\(show\\('pnpm run ${SCRIPT}'\\)\\.status !== 0\\)`))
  })
})

describe('non-contrast a11y gate — the properties that keep it honest', () => {
  const spec = read(SPEC)

  it('excludes exactly one rule, named literally', () => {
    const m = spec.match(/const EXCLUDED_RULES = \[([^\]]*)\] as const/)
    expect(m, 'EXCLUDED_RULES not found in its expected literal form').toBeTruthy()
    const rules = m![1]
      .split(',')
      .map((s) => s.trim().replace(/^['"]|['"]$/g, ''))
      .filter(Boolean)
    // Growing this list is how the gate would be silenced instead of the defect
    // being fixed. If a second exclusion is ever genuinely warranted it needs an
    // Owner decision and this assertion updated deliberately, not incidentally.
    expect(rules, `EXCLUDED_RULES must stay ['color-contrast'], got ${JSON.stringify(rules)}`).toEqual([
      'color-contrast',
    ])
  })

  it('carries all five fail-closed criteria', () => {
    for (const code of ['[S1]', '[S2]', '[S3]', '[S4]']) {
      expect(spec, `${code} assertion missing from the spec`).toContain(code)
    }
    // S5 is the ABSENCE of a rescue around analyze() — assert no try/catch wraps it.
    expect(
      spec.includes('try {') && spec.includes('analyze()'),
      'S5: analyze() must not be wrapped in try/catch — a scanner that throws must fail the gate',
    ).toBe(false)
  })

  it('freezes transitions, so its color-contrast number is reproducible', () => {
    expect(spec).toContain('transition:none !important')
    expect(spec).toContain('animation:none !important')
  })

  it('never becomes the excuse for narrowing the other suite', () => {
    // ── 2026-08-31: this assertion changed shape, deliberately ────────────────
    // It used to read: tests/a11y/docs-pages.spec.ts must not contain the string
    // "color-contrast" at all. That was the right guard while the Owner's
    // 2026-05-18 "no allowlist" decision stood — the only way to make that suite
    // green was to filter the rule out, so forbidding the string forbade the move.
    //
    // The Owner replaced that decision on 2026-08-31 (backlog INFRA-F86 残余③):
    // the accepted contrast debt is now a named, dated, shrink-only table. So the
    // string is expected to appear, and the guard has to move up a level — from
    // "you may not narrow it" to "you may only narrow it through the reviewed,
    // gated mechanism". The mechanism's own gate is
    // tests/a11y-contrast-exemptions-gate.test.ts; this assertion only checks that
    // THIS gate is not the thing being used to justify a shortcut over there.
    const red = read('tests/a11y/docs-pages.spec.ts')

    // Narrowing goes through the shared table, never an inline filter here.
    expect(
      red.includes("from '../lib/a11y-contrast-exemptions.mjs'"),
      'the red suite must narrow only via the reviewed exemption helper',
    ).toBe(true)

    // The half of the 2026-05-18 decision that did NOT change: no severity threshold.
    expect(
      /impact\s*[=!]==/.test(red),
      'no severity threshold: the a11y suite must not branch on impact',
    ).toBe(false)

    // And it must not have quietly adopted this gate's blunt instrument: excluding
    // a whole rule from the assertion is what the table exists to replace.
    expect(
      red.includes('EXCLUDED_RULES'),
      'the red suite must not exclude rules wholesale — that is what the table replaced',
    ).toBe(false)
  })
})
