teqo-backup/tests/unit/ciSkipInvariants.unit.spec.ts

200 lines
8.3 KiB
TypeScript

// @vitest-environment node
import { readdirSync, readFileSync } from 'node:fs'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
import { checkTestLocations } from '../../scripts/check-test-locations.mjs'
import { E2E_AFFECTED_MANIFEST } from '../../scripts/lib/e2e-affected-manifest.mjs'
import {
CANONICAL_E2E_SPEC_SUFFIX,
CANONICAL_E2E_TEST_DIR,
CANONICAL_INT_INCLUDE,
CANONICAL_UNIT_INCLUDE,
E2E_MANIFEST_DOMAIN_EXEMPT,
findMisplacedSpecPaths,
findUncoveredE2eDomainPrefixes,
HIGH_RISK_EXACT,
isBuildPath,
isCanonicalSpecPath,
isCodePath,
isHighRisk,
isMisplacedSpecPath,
isSrcPath,
isTestPath,
} from '../../scripts/lib/test-affected-core.mjs'
const repoRoot = join(fileURLToPath(new URL('.', import.meta.url)), '../..')
describe('ciSkipInvariants', () => {
it('exports shared path predicates used by classifiers and the lint guard', () => {
expect(isSrcPath('src/lib/x.ts')).toBe(true)
expect(isTestPath('tests/unit/x.unit.spec.ts')).toBe(true)
expect(isCodePath('tsconfig.json')).toBe(true)
expect(isBuildPath('public/favicon.ico')).toBe(true)
expect(isHighRisk('src/migrations/x.ts')).toBe(true)
expect(E2E_MANIFEST_DOMAIN_EXEMPT.has('shared')).toBe(true)
})
it('rejects misplaced *.spec|*.test files outside the three canonical trees', () => {
expect(isCanonicalSpecPath('tests/unit/foo.unit.spec.ts')).toBe(true)
expect(isCanonicalSpecPath('tests/unit/foo.unit.spec.tsx')).toBe(true)
expect(isCanonicalSpecPath('tests/int/foo.int.spec.ts')).toBe(true)
expect(isCanonicalSpecPath('tests/e2e/foo.e2e.spec.ts')).toBe(true)
for (const path of [
'src/utilities/foo.spec.ts',
'scripts/bar.test.ts',
'tests/unit/wrong.spec.ts',
'tests/int/wrong.spec.ts',
'tests/e2e/nested/foo.e2e.spec.ts',
'foo.test.tsx',
]) {
expect(isMisplacedSpecPath(path), path).toBe(true)
}
expect(findMisplacedSpecPaths(['tests/unit/ok.unit.spec.ts', 'src/x.spec.ts'])).toEqual([
'src/x.spec.ts',
])
})
it('repo walk finds no misplaced specs (same check as the lint job)', () => {
expect(checkTestLocations(repoRoot)).toEqual([])
})
it('vitest/playwright harness globs match the canonical constants', () => {
const unitConfig = readFileSync(join(repoRoot, 'vitest.unit.config.mts'), 'utf8')
const intConfig = readFileSync(join(repoRoot, 'vitest.config.mts'), 'utf8')
const playwrightConfig = readFileSync(join(repoRoot, 'playwright.config.ts'), 'utf8')
expect(unitConfig).toContain(`'${CANONICAL_UNIT_INCLUDE}'`)
expect(intConfig).toContain(`'${CANONICAL_INT_INCLUDE}'`)
expect(playwrightConfig).toContain(`'./${CANONICAL_E2E_TEST_DIR}'`)
expect(CANONICAL_E2E_SPEC_SUFFIX).toBe('.e2e.spec.ts')
})
it('keeps local e2e build artifacts outside the development dist directory', () => {
for (const file of [
'.gitignore',
'.prettierignore',
'eslint.config.mjs',
'playwright.config.ts',
'scripts/run-e2e-affected.mjs',
'tsconfig.json',
]) {
const source = readFileSync(join(repoRoot, file), 'utf8')
expect(source, file).not.toContain('.next/e2e')
expect(source, file).toContain('.next-e2e')
}
})
it('skip classifiers and invariant specs are high-risk (force full suite)', () => {
for (const path of [
'scripts/lib/test-affected-core.mjs',
'scripts/ci-scope.mjs',
'scripts/check-test-locations.mjs',
'scripts/check-plans-only-pr-closes.mjs',
'scripts/lib/plansOnlyClosesGuard.mjs',
'scripts/test-affected.mjs',
'scripts/e2e-affected.mjs',
'scripts/run-e2e-affected.mjs',
'scripts/vitest-changed-or-full.mjs',
'tests/unit/ciSkipInvariants.unit.spec.ts',
'tests/unit/testAffected.unit.spec.ts',
]) {
expect(HIGH_RISK_EXACT.has(path), path).toBe(true)
}
})
it('deploy is manual-only — never a push/schedule trigger (OPS71)', () => {
const deploy = readFileSync(join(repoRoot, '.github/workflows/deploy.yml'), 'utf8')
expect(deploy).toContain('workflow_dispatch:')
expect(deploy).not.toMatch(/^\s*push:/m)
expect(deploy).not.toMatch(/^\s*schedule:/m)
// The OPS65 production-change gate died with the Forgejo ci.yml: the
// manual dispatch always runs the full suite (verify) before deploy, so
// no PR workflow may reference the removed classifier.
const ciPr = readFileSync(join(repoRoot, '.github/workflows/ci-pr.yml'), 'utf8')
expect(ciPr).not.toContain('ci-classify-production.mjs')
})
it('deploy.yml chains verify → deploy-staging → deploy-production with separate environments (OPS103)', () => {
// One dispatch, one verify, two separately approved deploys. The gate is
// the GitHub Environment (reviewer on production, configured in the repo —
// not in this YAML); the chain is needs-based so a red staging fail-closes
// production. Comments are stripped first: the header prose also mentions
// these keys, and it must never satisfy the pin.
const raw = readFileSync(join(repoRoot, '.github/workflows/deploy.yml'), 'utf8')
const deploy = raw
.split('\n')
.filter((line) => !line.trimStart().startsWith('#'))
.join('\n')
expect(deploy).toContain(' deploy-staging:')
expect(deploy).toContain(' deploy-production:')
expect(deploy).toContain(' environment: staging')
expect(deploy).toContain(' environment: production')
expect(deploy).toContain(' needs: [deploy-staging]')
expect(deploy).toContain(' TEQO_ENV: staging')
expect(deploy).toContain(' TEQO_ENV: production')
expect(deploy).toContain("github.ref == 'refs/heads/main'")
expect(deploy).toContain("needs.deploy-staging.result == 'success'")
// Deploys share the homeserver compose/workspace: serialize them across
// runs without cancelling an in-flight deploy, and queue (never cancel) a
// previously pending one (`queue: max`).
expect(deploy).toContain(' group: deploy-homeserver')
expect(deploy).toContain(' cancel-in-progress: false')
expect(deploy).toContain(' queue: max')
})
it('every campaign domain dir is covered by the e2e affected manifest', () => {
const componentDirs = readdirSync(join(repoRoot, 'src/components/campaign'), {
withFileTypes: true,
})
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
const appRouteDirs = readdirSync(join(repoRoot, 'src/app/(campaign)/campanha/(app)'), {
withFileTypes: true,
})
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
const { missing } = findUncoveredE2eDomainPrefixes(
componentDirs,
appRouteDirs,
E2E_AFFECTED_MANIFEST,
)
expect(missing, `unmapped domains:\n${missing.join('\n')}`).toEqual([])
})
it('ci-pr wires the OPS86 never-zero fallbacks (curated e2e, unmapped-risk fail, changed-or-full)', () => {
// The fail-closed behavior IS the workflow wiring — pin it like the
// other CI contracts in this file.
const ciPr = readFileSync(join(repoRoot, '.github/workflows/ci-pr.yml'), 'utf8')
expect(ciPr).toContain("e2e_mode == 'selected' || steps.scope.outputs.e2e_mode == 'curated'")
expect(ciPr).toContain("e2e_mode == 'unmapped-risk'")
expect(ciPr).toContain('vitest-changed-or-full.mjs')
// The PR never runs e2e full.
expect(ciPr).not.toContain("e2e_mode == 'full'")
})
it('vitest-changed-or-full mirrors the package.json test scripts (config + unit DB guard)', () => {
// The wrapper re-declares the vitest config paths and the unit
// invalid-DATABASE_URL guard from the `test:unit`/`test:int` scripts; a
// drift changes what `--changed` detects and is silent. Pin the sync.
const pkg = JSON.parse(readFileSync(join(repoRoot, 'package.json'), 'utf8'))
const wrapper = readFileSync(join(repoRoot, 'scripts/vitest-changed-or-full.mjs'), 'utf8')
expect(pkg.scripts['test:unit']).toContain('./vitest.unit.config.mts')
expect(pkg.scripts['test:int']).toContain('./vitest.config.mts')
expect(wrapper).toContain('./vitest.unit.config.mts')
expect(wrapper).toContain('./vitest.config.mts')
const invalidUnitDbUrl = 'postgresql://invalid:invalid@127.0.0.1:1/unit_tests_must_not_connect'
expect(pkg.scripts['test:unit']).toContain(invalidUnitDbUrl)
expect(wrapper).toContain(invalidUnitDbUrl)
})
})