Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci-required.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
# ├── reusable-backend-unit.yml domain / application / CLI unit tests (Ubuntu + Windows)
# ├── reusable-api-integration.yml API integration tests (Ubuntu + Windows)
# ├── reusable-migration-validation.yml EF Core migration chain validation (TST-61)
# ├── reusable-frontend-unit.yml dev launcher + lint + typecheck + build + unit tests (Ubuntu + Windows)
# ├── reusable-frontend-unit.yml lint + typecheck + build + unit tests (Ubuntu + Windows); dev launcher suite on Ubuntu only (CI-07 #2331)
# ├── reusable-paper-color-audit.yml PAPER-12 hex-literal regression gate (#1008)
# ├── reusable-container-images.yml compose validation + image build (tar export/upload only for nightly/release callers, CI-09)
# ├── reusable-gitleaks.yml secret detection (PR scan, fail-on-detect) (#1132)
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/reusable-frontend-unit.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,10 @@ jobs:
# the job's remaining steps down with it. A passed windows-latest run of this step measured
# 6m31s, so 10 minutes fails a hang fast while leaving ~1.5x headroom on a slow runner. The
# suite's own watchdog trips at 9 minutes first and logs which launcher trees leaked.
# CI-07 #2331 under the SC-3 re-ruling (hosted minutes are Linux-only): the PowerShell launcher
# cases are local Windows evidence now, while the Bash launcher cases still run on the Linux leg.
- name: Run source launcher regression suite
if: runner.os == 'Linux'
timeout-minutes: 10
run: node --test --test-concurrency=1 --test-timeout=30000 scripts/ci/dev-up.test.mjs

Expand Down
2 changes: 1 addition & 1 deletion ci/policy.v1.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@
"api-integration-windows": { "checkName": "API Integration / API Integration (windows-latest)", "family": "windows", "runner": "selfHostedWindowsCompat", "trustedOnly": true, "hostedFallback": "hostedWindows", "description": "API integration suite on Windows (full duplicate today; MCP/process/SQLite contract shard after CI-06/CI-07)" },
"migration-validation": { "checkName": "Migration Validation / Migration Validation", "family": "persistence", "runner": "hostedLinux", "trustedOnly": false, "description": "EF Core migration chain validation" },
"frontend-unit-linux": { "checkName": "Frontend Unit / Frontend Unit (ubuntu-latest)", "family": "frontend", "runner": "selfHostedLinuxHeavy", "trustedOnly": true, "hostedFallback": "hostedLinux", "description": "lint + typecheck + build + coverage + bundle budget" },
"frontend-unit-windows": { "checkName": "Frontend Unit / Frontend Unit (windows-latest)", "family": "windows", "runner": "selfHostedWindowsCompat", "trustedOnly": true, "hostedFallback": "hostedWindows", "description": "dev launcher regression + full frontend duplicate today; launcher/platform subset after CI-08" },
"frontend-unit-windows": { "checkName": "Frontend Unit / Frontend Unit (windows-latest)", "family": "windows", "runner": "selfHostedWindowsCompat", "trustedOnly": true, "hostedFallback": "hostedWindows", "description": "full frontend duplicate today; launcher/platform subset after CI-08 (launcher suite runs on the Linux leg only since CI-07 #2331)" },
"paper-color-audit": { "checkName": "Paper Color Audit / Paper Color Audit", "family": "frontend", "runner": "hostedLinux", "trustedOnly": false, "description": "hex-literal regression gate" },
"container-images": { "checkName": "Container Images / Container Images", "family": "containers", "runner": "selfHostedLinuxHeavy", "trustedOnly": true, "hostedFallback": "hostedLinux", "description": "compose validation + image build (+ artifact export today)" },
"secret-scan": { "checkName": "Secret Scan / Gitleaks Scan", "family": "security", "runner": "hostedLinux", "trustedOnly": false, "description": "diff-scoped secret scan - required branch-protection context" },
Expand Down
13 changes: 12 additions & 1 deletion docs/TESTING_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

This is the active testing guide for Taskdeck.

Last Updated: 2026-09-02
Last Updated: 2026-09-05
Companion Active Docs:
- `docs/STATUS.md`
- `docs/IMPLEMENTATION_MASTERPLAN.md`
Expand Down Expand Up @@ -2338,6 +2338,17 @@ Required workflow: `.github/workflows/ci-required.yml`
- Lint + coverage-threshold Vitest + typecheck + build
- Ubuntu and Windows matrix
- Uploads JUnit + coverage artifacts (`test-results/`, `coverage/`) for triage

The source launcher regression suite (`scripts/ci/dev-up.test.mjs`) runs as a step of this job on
the **Linux leg only** (CI-07 `#2331`, SC-3: hosted minutes are Linux-only). The Bash launcher
cases therefore still run on every PR; the PowerShell launcher cases are local Windows evidence,
run from the repository root on Windows with
`node --test --test-concurrency=1 --test-timeout=30000 scripts/ci/dev-up.test.mjs`, until the
CI-04 `#2328` laptop runner is registered. On a Windows developer box that command is known to
red its `Bash:` cases under Git Bash (the local `#2378` cohort, recorded in `docs/STATUS.md`); the
`PowerShell:` cases are the evidence being asked for, so read the result per case rather than as
one pass/fail. The Windows leg keeps lint, typecheck, build and coverage unchanged.
`scripts/ci/smart-ci/launcher-suite-placement.test.mjs` pins that placement.
- `container-images`
- Runs `scripts/deploy/Test-TaskdeckReverseProxyConfig.ps1` against all four machine prefixes,
static/rendered-template parity, forwarding/timeouts, hub WebSockets, MCP buffering, and SPA fallback
Expand Down
2 changes: 1 addition & 1 deletion docs/ci/SMART_CI.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ The lanes are the existing reusable workflows (ADR-0013), parameterized rather t
| backend-unit | `reusable-backend-unit.yml` (Domain/Application/CLI) | Linux semantic; Windows only via the contract | |
| api-integration | `reusable-api-integration.yml` → behavioural shards (CI-06) | Linux semantic; Windows contract shard | |
| migration | `reusable-migration-validation.yml` | hosted Linux | selected by persistence groups |
| frontend | `reusable-frontend-unit.yml` | Linux once; Windows launcher/platform subset | CI-08 |
| frontend | `reusable-frontend-unit.yml` | Linux once (launcher suite Linux-only since CI-07 #2331); Windows platform subset without the launcher suite | CI-08 |
| e2e-journey | `reusable-e2e-smoke.yml` by journey | self-hosted Linux / hosted | CI-08 |
| containers | `reusable-container-images.yml`, `reusable-container-integration.yml` | self-hosted Linux / hosted | risk-gated (CI-08) |
| windows-compat | worktree-helper harness, launchers, dev-up, MCP host, SQLite, desktop | Windows (self-hosted after CI-04) | CI-07 |
Expand Down
119 changes: 119 additions & 0 deletions scripts/ci/smart-ci/launcher-suite-placement.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import { test } from 'node:test'

const frontendUnitWorkflowUrl = new URL(
'../../../.github/workflows/reusable-frontend-unit.yml',
import.meta.url,
)

const LAUNCHER_STEP_NAME = 'Run source launcher regression suite'
const UNCONDITIONAL_STEP_NAMES = [
'Run frontend lint',
'Run frontend typecheck',
'Run frontend build',
'Run frontend tests with coverage thresholds',
]

function workflowLines(workflow) {
return workflow.replaceAll('\r\n', '\n').split('\n')
}

function extractFrontendUnitJob(workflow) {
const lines = workflowLines(workflow)
const jobsIndex = lines.findIndex((line) => line.trim() === 'jobs:')
assert.notEqual(jobsIndex, -1, 'reusable-frontend-unit.yml is missing the top-level jobs mapping')

const jobIndex = lines.findIndex(
(line, index) => index > jobsIndex && /^ {2}frontend-unit:\s*$/.test(line),
)
assert.notEqual(jobIndex, -1, 'reusable-frontend-unit.yml is missing the frontend-unit job')

const nextJobIndex = lines.findIndex(
(line, index) => index > jobIndex && /^ {2}[A-Za-z0-9_-]+:\s*$/.test(line),
)
return lines.slice(jobIndex, nextJobIndex === -1 ? lines.length : nextJobIndex)
}

function extractMatrixOperatingSystems(jobLines) {
const osIndex = jobLines.findIndex((line) => /^ {8}os:\s*$/.test(line))
assert.notEqual(osIndex, -1, 'frontend-unit is missing its matrix.os list')

const operatingSystems = []
for (const line of jobLines.slice(osIndex + 1)) {
const item = line.match(/^ {10}-\s*([A-Za-z0-9_.-]+)\s*$/)
if (item) {
operatingSystems.push(item[1])
continue
}
if (line.trim() === '') continue
break
}
return operatingSystems
}

function extractSteps(jobLines) {
const stepsIndex = jobLines.findIndex((line) => /^ {4}steps:\s*$/.test(line))
assert.notEqual(stepsIndex, -1, 'frontend-unit is missing its steps list')

const steps = []
let current = null
for (const line of jobLines.slice(stepsIndex + 1)) {
if (/^ {6}- /.test(line)) {
const name = line.match(/^ {6}-\s*name:\s*(.+?)\s*$/)
current = { name: name ? name[1] : null, body: [line] }
steps.push(current)
continue
}
if (current) current.body.push(line)
}
return steps.map((step) => ({ name: step.name, body: step.body.join('\n') }))
}

function findStep(steps, name) {
const step = steps.find((candidate) => candidate.name === name)
assert.ok(step, `frontend-unit is missing the "${name}" step`)
return step
}

function extractStepCondition(step) {
const condition = step.body.match(/^ {8}if:\s*(.+?)\s*$/m)
return condition ? condition[1] : null
}

test('the launcher regression suite runs on the Linux leg only (#2331, SC-3)', async () => {
const workflow = await readFile(frontendUnitWorkflowUrl, 'utf8')
const steps = extractSteps(extractFrontendUnitJob(workflow))
const launcherStep = findStep(steps, LAUNCHER_STEP_NAME)

assert.match(
launcherStep.body,
/^ {8}run: node --test --test-concurrency=1 --test-timeout=30000 scripts\/ci\/dev-up\.test\.mjs$/m,
'the launcher step must still run scripts/ci/dev-up.test.mjs',
)
assert.equal(
extractStepCondition(launcherStep),
"runner.os == 'Linux'",
'the launcher step must be gated to the Linux leg',
)
})

test('the frontend-unit matrix still covers both hosted operating systems', async () => {
const workflow = await readFile(frontendUnitWorkflowUrl, 'utf8')
const operatingSystems = extractMatrixOperatingSystems(extractFrontendUnitJob(workflow))

assert.deepEqual([...operatingSystems].sort(), ['ubuntu-latest', 'windows-latest'])
})

test('lint, typecheck, build and coverage stay unconditional on both legs', async () => {
const workflow = await readFile(frontendUnitWorkflowUrl, 'utf8')
const steps = extractSteps(extractFrontendUnitJob(workflow))

for (const name of UNCONDITIONAL_STEP_NAMES) {
assert.equal(
extractStepCondition(findStep(steps, name)),
null,
`"${name}" must carry no OS condition`,
)
}
})
Loading