@erclx/canon 4.52.0 → 4.54.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -0
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/claude-address-review/SKILL.md +28 -11
- package/claude/skills/claude-feature/SKILL.md +1 -1
- package/claude/skills/claude-orchestrate/references/orchestrator-dispatch.md +4 -1
- package/claude/skills/claude-planner/SKILL.md +1 -1
- package/docs/agents/commands.md +79 -78
- package/docs/agents/demo.md +11 -8
- package/docs/agents/index.md +1 -1
- package/docs/agents/indexes.md +17 -1
- package/docs/agents/tasks.md +1 -1
- package/governance/rules/core/085-worktrees.md +2 -0
- package/package.json +1 -1
- package/scripts/core/regen-agent-fixture.sh +235 -0
- package/src/commands/demo.ts +25 -1
- package/src/commands/indexes.ts +67 -0
- package/src/commands/labels.ts +6 -0
- package/src/commands/tasks.ts +5 -3
- package/src/demo/container.ts +61 -0
- package/src/demo/drive.ts +67 -9
- package/src/indexes/list.ts +64 -0
- package/src/indexes/render.ts +1 -1
- package/src/labels/event.ts +24 -0
- package/src/records/validate.ts +80 -2
- package/src/tasks/answers.ts +19 -13
- package/tooling/astro/configs/playwright.config.ts +4 -1
- package/tooling/astro/reference.md +1 -1
- package/tooling/vite-react/configs/playwright.config.ts +1 -0
- package/tooling/web/configs/.github/workflows/verify.yml +23 -1
- package/tooling/web/configs/e2e/screenshot.ts +36 -1
- package/tooling/web/configs/scripts/screenshot.sh +1 -1
- package/tooling/web/manifest.toml +2 -1
- package/tooling/web/reference.md +10 -1
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { dirname, relative, resolve } from 'node:path'
|
|
2
|
+
import { readField, readFrontmatter } from '@/indexes/frontmatter'
|
|
3
|
+
import { collectEntries } from '@/indexes/render'
|
|
4
|
+
import { listIndexes } from '@/indexes/walk'
|
|
5
|
+
|
|
6
|
+
export interface CatalogEntry {
|
|
7
|
+
readonly path: string
|
|
8
|
+
readonly title: string
|
|
9
|
+
readonly description: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface IndexCatalog {
|
|
13
|
+
readonly entries: CatalogEntry[]
|
|
14
|
+
readonly errors: string[]
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Flattens every folder index under `root` into one queryable catalog.
|
|
19
|
+
*
|
|
20
|
+
* A folder's own frontmatter error lands in `errors` without dropping the
|
|
21
|
+
* rest of the walk, the same isolation `regenOne` gives one folder.
|
|
22
|
+
*/
|
|
23
|
+
export async function buildIndexCatalog(root: string): Promise<IndexCatalog> {
|
|
24
|
+
const indexPaths = await listIndexes(root)
|
|
25
|
+
const entries: CatalogEntry[] = []
|
|
26
|
+
const errors: string[] = []
|
|
27
|
+
|
|
28
|
+
for (const indexPath of indexPaths) {
|
|
29
|
+
const dir = dirname(indexPath)
|
|
30
|
+
const frontmatter = await readFrontmatter(indexPath)
|
|
31
|
+
const title = readField(frontmatter, 'title')
|
|
32
|
+
const subtitle = readField(frontmatter, 'subtitle')
|
|
33
|
+
|
|
34
|
+
if (!title || !subtitle) {
|
|
35
|
+
errors.push(
|
|
36
|
+
`missing frontmatter field "title" or "subtitle" in ${indexPath}`,
|
|
37
|
+
)
|
|
38
|
+
} else {
|
|
39
|
+
entries.push({
|
|
40
|
+
path: relative(root, indexPath),
|
|
41
|
+
title,
|
|
42
|
+
description: subtitle,
|
|
43
|
+
})
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const collected = await collectEntries(dir)
|
|
47
|
+
if (!collected.ok) {
|
|
48
|
+
errors.push(...collected.errors)
|
|
49
|
+
continue
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
for (const entry of collected.entries) {
|
|
53
|
+
entries.push({
|
|
54
|
+
path: relative(root, resolve(dir, entry.name)),
|
|
55
|
+
title: entry.title,
|
|
56
|
+
description: entry.description,
|
|
57
|
+
})
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
entries.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0))
|
|
62
|
+
|
|
63
|
+
return { entries, errors }
|
|
64
|
+
}
|
package/src/indexes/render.ts
CHANGED
|
@@ -64,7 +64,7 @@ async function readIndexHead(
|
|
|
64
64
|
return { ok: true, head: { raw: frontmatter.raw, title, subtitle } }
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
-
async function collectEntries(
|
|
67
|
+
export async function collectEntries(
|
|
68
68
|
dir: string,
|
|
69
69
|
): Promise<
|
|
70
70
|
{ ok: true; entries: IndexEntry[] } | { ok: false; errors: string[] }
|
package/src/labels/event.ts
CHANGED
|
@@ -6,6 +6,8 @@ export type ScanInputRefusal =
|
|
|
6
6
|
| 'unreadable-event'
|
|
7
7
|
| 'not-a-pull-request'
|
|
8
8
|
| 'unreadable-review'
|
|
9
|
+
| 'conflicting-body-input'
|
|
10
|
+
| 'unreadable-body-file'
|
|
9
11
|
|
|
10
12
|
export type ResolvedScanInput =
|
|
11
13
|
| {
|
|
@@ -26,6 +28,7 @@ export interface ScanInputOptions {
|
|
|
26
28
|
readonly event?: string
|
|
27
29
|
readonly title?: string
|
|
28
30
|
readonly body?: string
|
|
31
|
+
readonly bodyFile?: string
|
|
29
32
|
readonly head?: string
|
|
30
33
|
}
|
|
31
34
|
|
|
@@ -46,6 +49,27 @@ export function resolveScanInput(opts: ScanInputOptions): ResolvedScanInput {
|
|
|
46
49
|
let headRefName = opts.head
|
|
47
50
|
let source: 'pull-request' | 'review' = 'pull-request'
|
|
48
51
|
|
|
52
|
+
if (opts.bodyFile !== undefined) {
|
|
53
|
+
if (body !== undefined) {
|
|
54
|
+
return {
|
|
55
|
+
kind: 'refused',
|
|
56
|
+
reason: 'conflicting-body-input',
|
|
57
|
+
message:
|
|
58
|
+
'--body and --body-file cannot both be given, since only one text can be scanned.',
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
try {
|
|
63
|
+
body = readFileSync(opts.bodyFile, 'utf8')
|
|
64
|
+
} catch {
|
|
65
|
+
return {
|
|
66
|
+
kind: 'refused',
|
|
67
|
+
reason: 'unreadable-body-file',
|
|
68
|
+
message: `${opts.bodyFile} could not be read, so no body was there to scan.`,
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
49
73
|
if (opts.event !== undefined) {
|
|
50
74
|
let raw: string
|
|
51
75
|
try {
|
package/src/records/validate.ts
CHANGED
|
@@ -72,6 +72,7 @@ export const FINDING_KINDS = [
|
|
|
72
72
|
'closing-partial',
|
|
73
73
|
'item-incomplete',
|
|
74
74
|
'category-mismatch',
|
|
75
|
+
'operator-call-phrasing',
|
|
75
76
|
] as const
|
|
76
77
|
|
|
77
78
|
export type FindingKind = (typeof FINDING_KINDS)[number]
|
|
@@ -157,6 +158,53 @@ export function isSharedScratch(kind: RecordKind): boolean {
|
|
|
157
158
|
const NONE_IDENTIFIED = 'None identified.'
|
|
158
159
|
const NUMBERED_FILE = /^\d{2}-[a-z0-9]+(-[a-z0-9]+)*\.md$/
|
|
159
160
|
|
|
161
|
+
/**
|
|
162
|
+
* The suggestion the plan standard fixes for a question that turns on the
|
|
163
|
+
* operator's preference rather than on a technical default. `src/tasks/answers.ts`
|
|
164
|
+
* reads this alongside `normalizeOperatorCall` so the dispatch gate and this
|
|
165
|
+
* write-time check cannot drift into recognizing different spellings.
|
|
166
|
+
*/
|
|
167
|
+
export const OPERATOR_CALL = 'needs your call'
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* The two demonstrated paraphrases of `OPERATOR_CALL`, longer variant first so
|
|
171
|
+
* it is not read as a shorter match sitting inside it: `operator's call` is a
|
|
172
|
+
* substring of `the operator's call`, and checking it first would rewrite the
|
|
173
|
+
* `the` into place with the wrong phrase on either side.
|
|
174
|
+
*/
|
|
175
|
+
const OPERATOR_CALL_PHRASES = [
|
|
176
|
+
"the operator's call",
|
|
177
|
+
"operator's call",
|
|
178
|
+
] as const
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Reads a recognized paraphrase as the canonical phrase, so a caller testing
|
|
182
|
+
* `startsWith(OPERATOR_CALL)` sees one spelling regardless of which wording the
|
|
183
|
+
* author used. Case-insensitive, since the corpus capitalizes a suggestion's
|
|
184
|
+
* first word, and it rewrites only the matched span so the reason text either
|
|
185
|
+
* side of it survives untouched.
|
|
186
|
+
*
|
|
187
|
+
* Short-circuits on an already-canonical opening before scanning the rest of
|
|
188
|
+
* the text. Without that, a suggestion already opening with `needs your call`
|
|
189
|
+
* that goes on to mention a paraphrase inside its own reason, such as
|
|
190
|
+
* `needs your call, since the operator's call outranks a default`, has that
|
|
191
|
+
* later occurrence rewritten too, which garbles the reason the dispatcher
|
|
192
|
+
* reports rather than leaving it as the author wrote it.
|
|
193
|
+
*/
|
|
194
|
+
export function normalizeOperatorCall(text: string): string {
|
|
195
|
+
const lower = text.toLowerCase()
|
|
196
|
+
if (lower.startsWith(OPERATOR_CALL)) return text
|
|
197
|
+
|
|
198
|
+
for (const phrase of OPERATOR_CALL_PHRASES) {
|
|
199
|
+
const at = lower.indexOf(phrase)
|
|
200
|
+
if (at !== -1) {
|
|
201
|
+
return `${text.slice(0, at)}your call${text.slice(at + phrase.length)}`
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return text
|
|
206
|
+
}
|
|
207
|
+
|
|
160
208
|
function finding(
|
|
161
209
|
kind: FindingKind,
|
|
162
210
|
record: string,
|
|
@@ -295,6 +343,9 @@ function shorten(label: string): string {
|
|
|
295
343
|
return label.length > 60 ? `${label.slice(0, 57)}...` : label
|
|
296
344
|
}
|
|
297
345
|
|
|
346
|
+
const SUGGESTED_PREFIX = '- Suggested:'
|
|
347
|
+
const ANSWER_PREFIX = '- Answer:'
|
|
348
|
+
|
|
298
349
|
function checkQuestionContract(name: string, lines: string[]): Finding[] {
|
|
299
350
|
if (lines.some((line) => line.trim() === NONE_IDENTIFIED)) return []
|
|
300
351
|
|
|
@@ -302,8 +353,12 @@ function checkQuestionContract(name: string, lines: string[]): Finding[] {
|
|
|
302
353
|
|
|
303
354
|
for (const question of readQuestions(lines)) {
|
|
304
355
|
const subject = shorten(question.label)
|
|
356
|
+
const suggested = question.body.find((line) =>
|
|
357
|
+
line.startsWith(SUGGESTED_PREFIX),
|
|
358
|
+
)
|
|
359
|
+
const answer = question.body.find((line) => line.startsWith(ANSWER_PREFIX))
|
|
305
360
|
|
|
306
|
-
if (!
|
|
361
|
+
if (!suggested) {
|
|
307
362
|
findings.push(
|
|
308
363
|
finding(
|
|
309
364
|
'suggestion-missing',
|
|
@@ -314,7 +369,7 @@ function checkQuestionContract(name: string, lines: string[]): Finding[] {
|
|
|
314
369
|
)
|
|
315
370
|
}
|
|
316
371
|
|
|
317
|
-
if (!
|
|
372
|
+
if (!answer) {
|
|
318
373
|
findings.push(
|
|
319
374
|
finding(
|
|
320
375
|
'question-unanswerable',
|
|
@@ -324,6 +379,29 @@ function checkQuestionContract(name: string, lines: string[]): Finding[] {
|
|
|
324
379
|
),
|
|
325
380
|
)
|
|
326
381
|
}
|
|
382
|
+
|
|
383
|
+
if (
|
|
384
|
+
suggested &&
|
|
385
|
+
answer &&
|
|
386
|
+
answer.slice(ANSWER_PREFIX.length).trim() === ''
|
|
387
|
+
) {
|
|
388
|
+
const text = suggested.slice(SUGGESTED_PREFIX.length).trim()
|
|
389
|
+
const normalized = normalizeOperatorCall(text)
|
|
390
|
+
|
|
391
|
+
if (
|
|
392
|
+
normalized.toLowerCase().startsWith(OPERATOR_CALL) &&
|
|
393
|
+
!text.toLowerCase().startsWith(OPERATOR_CALL)
|
|
394
|
+
) {
|
|
395
|
+
findings.push(
|
|
396
|
+
finding(
|
|
397
|
+
'operator-call-phrasing',
|
|
398
|
+
name,
|
|
399
|
+
subject,
|
|
400
|
+
'defers to the operator over a blank Answer without opening with the canonical needs your call, so a paraphrase reaches the corpus instead of the fixed spelling.',
|
|
401
|
+
),
|
|
402
|
+
)
|
|
403
|
+
}
|
|
404
|
+
}
|
|
327
405
|
}
|
|
328
406
|
|
|
329
407
|
return findings
|
package/src/tasks/answers.ts
CHANGED
|
@@ -3,20 +3,17 @@ import { readFile } from 'node:fs/promises'
|
|
|
3
3
|
import { isAbsolute, relative, resolve } from 'node:path'
|
|
4
4
|
import { isUnder } from '@/paths'
|
|
5
5
|
import { recordDir, recordDirs } from '@/record-root'
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
normalizeOperatorCall,
|
|
8
|
+
OPERATOR_CALL,
|
|
9
|
+
readQuestions,
|
|
10
|
+
splitPlanSections,
|
|
11
|
+
} from '@/records/validate'
|
|
7
12
|
|
|
8
13
|
const PLANS = 'plans'
|
|
9
14
|
const TASKS = 'tasks'
|
|
10
15
|
const ARCHIVE = 'archive'
|
|
11
16
|
|
|
12
|
-
/**
|
|
13
|
-
* The suggestion the plan standard fixes for a question that turns on the
|
|
14
|
-
* operator's preference rather than on a technical default. Every other
|
|
15
|
-
* suggestion is accepted by a blank slot, so only this phrase over an empty
|
|
16
|
-
* `- Answer:` is a stop.
|
|
17
|
-
*/
|
|
18
|
-
const OPERATOR_CALL = 'needs your call'
|
|
19
|
-
|
|
20
17
|
const SUGGESTED_PREFIX = '- Suggested:'
|
|
21
18
|
const ANSWER_PREFIX = '- Answer:'
|
|
22
19
|
|
|
@@ -112,9 +109,15 @@ function isAnswered(body: readonly string[]): boolean {
|
|
|
112
109
|
* behind a full stop, so both separators come off. Reporting the phrase with
|
|
113
110
|
* whatever punctuation followed it hands the operator a stray mark where the
|
|
114
111
|
* reason should start.
|
|
112
|
+
*
|
|
113
|
+
* Takes the normalized suggestion rather than the raw one, so the length
|
|
114
|
+
* stripped from the front is always `OPERATOR_CALL`'s own regardless of which
|
|
115
|
+
* recognized wording the author wrote. The `operator's call` and
|
|
116
|
+
* `the operator's call` variants read longer than `your call`, and slicing by
|
|
117
|
+
* the canonical length against the raw text would cut into the reason itself.
|
|
115
118
|
*/
|
|
116
|
-
function reasonOf(
|
|
117
|
-
const rest =
|
|
119
|
+
function reasonOf(normalized: string): string {
|
|
120
|
+
const rest = normalized.slice(OPERATOR_CALL.length).replace(/^[,.;:\s]+/, '')
|
|
118
121
|
|
|
119
122
|
return rest.length > 0 ? rest : 'no reason stated'
|
|
120
123
|
}
|
|
@@ -130,10 +133,13 @@ function openQuestions(lines: readonly string[]): OpenQuestion[] {
|
|
|
130
133
|
|
|
131
134
|
for (const question of readQuestions(lines)) {
|
|
132
135
|
const suggested = suggestionOf(question.body)
|
|
133
|
-
if (!suggested
|
|
136
|
+
if (!suggested) continue
|
|
137
|
+
|
|
138
|
+
const normalized = normalizeOperatorCall(suggested)
|
|
139
|
+
if (!normalized.toLowerCase().startsWith(OPERATOR_CALL)) continue
|
|
134
140
|
if (isAnswered(question.body)) continue
|
|
135
141
|
|
|
136
|
-
open.push({ label: question.label, why: reasonOf(
|
|
142
|
+
open.push({ label: question.label, why: reasonOf(normalized) })
|
|
137
143
|
}
|
|
138
144
|
|
|
139
145
|
return open
|
|
@@ -6,6 +6,7 @@ const baseURL = `http://localhost:${4321 + (Number(process.env.WORKTREE_PORT_OFF
|
|
|
6
6
|
export default defineConfig({
|
|
7
7
|
testDir: 'e2e',
|
|
8
8
|
forbidOnly: isCI,
|
|
9
|
+
// Absorbs shared-runner noise on a fresh scaffold with no flake history, at the cost of hiding a real defect until someone reads the run summary.
|
|
9
10
|
retries: isCI ? 2 : 0,
|
|
10
11
|
// No override here: Playwright's own CPU-derived default suits every CI runner
|
|
11
12
|
reporter: isCI ? 'list' : 'html',
|
|
@@ -19,7 +20,9 @@ export default defineConfig({
|
|
|
19
20
|
{ name: 'webkit', use: { ...devices['Desktop Safari'] } },
|
|
20
21
|
],
|
|
21
22
|
webServer: {
|
|
22
|
-
command:
|
|
23
|
+
command: process.env.DIST_PREBUILT
|
|
24
|
+
? 'bun run preview'
|
|
25
|
+
: 'bun run build && bun run preview',
|
|
23
26
|
url: baseURL,
|
|
24
27
|
reuseExistingServer: false,
|
|
25
28
|
env: { ASTRO_PREVIEW_BACKGROUND: '0' },
|
|
@@ -19,7 +19,7 @@ The astro stack covers Astro + TypeScript projects: content sites, marketing sit
|
|
|
19
19
|
|
|
20
20
|
- `astro.config.mjs`: `@astrojs/react` integration, `@tailwindcss/vite` in `vite.plugins`, `@/` path alias via `vite.resolve.alias`, `ASTRO_SITE` env for the `site` field. Port `4321` plus `WORKTREE_PORT_OFFSET` at `server.port`, with `strictPort` under `vite.server` and `vite.preview`. Astro merges the user's `vite` block into the config backing both its dev and its static preview server, and feeds `server.port` through as the preview port, so the port sits at the top level while the bind guarantee sits under `vite`.
|
|
21
21
|
- `vitest.config.ts`: uses `getViteConfig` from `astro/config` (not `mergeConfig`). jsdom, globals, setup file, `passWithNoTests: true`, v8 coverage, `**/*.astro` in coverage excludes.
|
|
22
|
-
- `playwright.config.ts`: all browsers, `webServer` runs `bun run build && bun run preview` on port `4321` plus `WORKTREE_PORT_OFFSET`, `reuseExistingServer: false`. Astro's dev/prod gap is wide (MDX, island hydration, asset optimization), so E2E always tests the built `dist/`.
|
|
22
|
+
- `playwright.config.ts`: all browsers, `webServer` runs `bun run build && bun run preview` on port `4321` plus `WORKTREE_PORT_OFFSET`, `reuseExistingServer: false`. Astro's dev/prod gap is wide (MDX, island hydration, asset optimization), so E2E always tests the built `dist/`. `DIST_PREBUILT` set in the environment drops the `build` half, running `bun run preview` alone against a `dist/` a prior CI job already produced.
|
|
23
23
|
- `tsconfig.json`: extends `astro/tsconfigs/strict`, adds `skipLibCheck`, `vitest/globals` and `@testing-library/jest-dom` in types, `@/` paths.
|
|
24
24
|
- `eslint.config.js`: overrides the web layer. Adds `eslint-plugin-astro` (`.astro` parser via `astro-eslint-parser`). React-hooks scoped to `.jsx`/`.tsx` only (`.astro` is not React). `src/pages/**` exempt from filename and folder naming conventions because Astro's file-based routing ties names to URL segments.
|
|
25
25
|
|
|
@@ -6,6 +6,7 @@ const baseURL = `http://localhost:${5173 + (Number(process.env.WORKTREE_PORT_OFF
|
|
|
6
6
|
export default defineConfig({
|
|
7
7
|
testDir: 'e2e',
|
|
8
8
|
forbidOnly: isCI,
|
|
9
|
+
// Absorbs shared-runner noise on a fresh scaffold with no flake history, at the cost of hiding a real defect until someone reads the run summary.
|
|
9
10
|
retries: isCI ? 2 : 0,
|
|
10
11
|
// No override here: Playwright's own CPU-derived default suits every CI runner
|
|
11
12
|
reporter: isCI ? 'list' : 'html',
|
|
@@ -5,6 +5,10 @@ on:
|
|
|
5
5
|
branches: [main]
|
|
6
6
|
workflow_dispatch:
|
|
7
7
|
|
|
8
|
+
concurrency:
|
|
9
|
+
group: verify-${{ github.ref }}
|
|
10
|
+
cancel-in-progress: true
|
|
11
|
+
|
|
8
12
|
jobs:
|
|
9
13
|
static-checks:
|
|
10
14
|
name: 🔍 Static Checks
|
|
@@ -90,6 +94,13 @@ jobs:
|
|
|
90
94
|
- name: Build
|
|
91
95
|
run: bun run build
|
|
92
96
|
|
|
97
|
+
- name: Upload Build Output
|
|
98
|
+
uses: actions/upload-artifact@v4
|
|
99
|
+
with:
|
|
100
|
+
name: build-output
|
|
101
|
+
path: dist/
|
|
102
|
+
retention-days: 1
|
|
103
|
+
|
|
93
104
|
e2e-tests:
|
|
94
105
|
name: 🎭 E2E Tests
|
|
95
106
|
runs-on: ubuntu-latest
|
|
@@ -111,18 +122,29 @@ jobs:
|
|
|
111
122
|
- name: Install Dependencies
|
|
112
123
|
run: bun install --frozen-lockfile
|
|
113
124
|
|
|
125
|
+
- name: Download Build Output
|
|
126
|
+
uses: actions/download-artifact@v4
|
|
127
|
+
with:
|
|
128
|
+
name: build-output
|
|
129
|
+
path: dist/
|
|
130
|
+
|
|
131
|
+
- name: Install Playwright System Dependencies
|
|
132
|
+
run: bunx playwright install-deps chromium
|
|
133
|
+
|
|
114
134
|
- name: Cache Playwright Browsers
|
|
115
135
|
id: playwright-cache
|
|
116
136
|
uses: actions/cache@v4
|
|
117
137
|
with:
|
|
118
138
|
path: ~/.cache/ms-playwright
|
|
119
|
-
key: playwright-${{ runner.os }}-${{ hashFiles('**/package.json') }}
|
|
139
|
+
key: playwright-chromium-${{ runner.os }}-${{ hashFiles('**/package.json') }}
|
|
120
140
|
|
|
121
141
|
- name: Install Playwright Chromium
|
|
122
142
|
if: steps.playwright-cache.outputs.cache-hit != 'true'
|
|
123
143
|
run: bunx playwright install chromium
|
|
124
144
|
|
|
125
145
|
- name: Run E2E Tests
|
|
146
|
+
env:
|
|
147
|
+
DIST_PREBUILT: 'true'
|
|
126
148
|
run: bun run test:e2e --project=chromium
|
|
127
149
|
|
|
128
150
|
- name: Upload E2E Report
|
|
@@ -24,10 +24,29 @@ const CASES: CaptureCase[] = [
|
|
|
24
24
|
},
|
|
25
25
|
]
|
|
26
26
|
|
|
27
|
+
const args = process.argv.slice(2)
|
|
28
|
+
const checkConsoleClean = args.includes('--check-console-clean')
|
|
29
|
+
const requireBaseUrl = args.includes('--require-base-url')
|
|
30
|
+
|
|
31
|
+
if (requireBaseUrl && !process.env.SCREENSHOT_BASE_URL) {
|
|
32
|
+
console.error('SCREENSHOT_BASE_URL is required with --require-base-url')
|
|
33
|
+
process.exit(1)
|
|
34
|
+
}
|
|
35
|
+
|
|
27
36
|
const BASE_URL = process.env.SCREENSHOT_BASE_URL ?? 'http://localhost:4173'
|
|
28
|
-
|
|
37
|
+
|
|
38
|
+
let hostname: string
|
|
39
|
+
try {
|
|
40
|
+
hostname = new URL(BASE_URL).hostname
|
|
41
|
+
} catch {
|
|
42
|
+
console.error(`SCREENSHOT_BASE_URL is not a valid URL: ${BASE_URL}`)
|
|
43
|
+
process.exit(1)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const OUT_DIR = path.join('screenshots', hostname)
|
|
29
47
|
|
|
30
48
|
const browser = await chromium.launch()
|
|
49
|
+
const consoleErrors: string[] = []
|
|
31
50
|
|
|
32
51
|
for (const captureCase of CASES) {
|
|
33
52
|
const context = await browser.newContext({
|
|
@@ -35,6 +54,16 @@ for (const captureCase of CASES) {
|
|
|
35
54
|
})
|
|
36
55
|
const page = await context.newPage()
|
|
37
56
|
|
|
57
|
+
if (checkConsoleClean) {
|
|
58
|
+
page.on('console', (msg) => {
|
|
59
|
+
if (msg.type() === 'error') {
|
|
60
|
+
consoleErrors.push(
|
|
61
|
+
`${captureCase.section}/${captureCase.theme}: ${msg.text()}`,
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
})
|
|
65
|
+
}
|
|
66
|
+
|
|
38
67
|
if (captureCase.setup) await captureCase.setup(page)
|
|
39
68
|
|
|
40
69
|
await page.goto(`${BASE_URL}${captureCase.route}`)
|
|
@@ -51,3 +80,9 @@ for (const captureCase of CASES) {
|
|
|
51
80
|
}
|
|
52
81
|
|
|
53
82
|
await browser.close()
|
|
83
|
+
|
|
84
|
+
if (checkConsoleClean && consoleErrors.length > 0) {
|
|
85
|
+
console.error('console errors detected:')
|
|
86
|
+
for (const error of consoleErrors) console.error(` ${error}`)
|
|
87
|
+
process.exit(1)
|
|
88
|
+
}
|
|
@@ -11,7 +11,7 @@ fi
|
|
|
11
11
|
bun run build
|
|
12
12
|
bun run preview >/dev/null 2>&1 &
|
|
13
13
|
PREVIEW_PID=$!
|
|
14
|
-
trap 'kill "$PREVIEW_PID" 2>/dev/null; wait "$PREVIEW_PID" 2>/dev/null || true' EXIT
|
|
14
|
+
trap 'kill "$PREVIEW_PID" 2>/dev/null || true; wait "$PREVIEW_PID" 2>/dev/null || true' EXIT
|
|
15
15
|
|
|
16
16
|
for _ in $(seq 1 40); do
|
|
17
17
|
if curl -sSf "http://localhost:$PREVIEW_PORT/" >/dev/null 2>&1; then
|
|
@@ -47,6 +47,7 @@ packages = [
|
|
|
47
47
|
"test:e2e:ui" = "WORKTREE_PORT_OFFSET=$(bash scripts/worktree-port.sh) && export WORKTREE_PORT_OFFSET && playwright test --ui"
|
|
48
48
|
"test:e2e:report" = "playwright show-report"
|
|
49
49
|
"check:full" = "./scripts/verify.sh && bun run test:e2e"
|
|
50
|
+
"smoke:prod" = "bun e2e/screenshot.ts --check-console-clean --require-base-url"
|
|
50
51
|
|
|
51
52
|
[scripts.override]
|
|
52
53
|
"lint" = "eslint . --max-warnings 0"
|
|
@@ -55,5 +56,5 @@ packages = [
|
|
|
55
56
|
[gitignore]
|
|
56
57
|
"# Build" = ["dist/"]
|
|
57
58
|
"# Coverage" = ["coverage/"]
|
|
58
|
-
"# Playwright" = ["test-results/", "playwright-report/", "blob-report/", "playwright/.cache/"
|
|
59
|
+
"# Playwright" = ["test-results/", "playwright-report/", "blob-report/", "playwright/.cache/"]
|
|
59
60
|
"# VSCode" = [".vscode/*", "!.vscode/extensions.json", "!.vscode/settings.json"]
|
package/tooling/web/reference.md
CHANGED
|
@@ -12,7 +12,7 @@ Golden config files live in `tooling/web/configs/` and are copied into the targe
|
|
|
12
12
|
|
|
13
13
|
- `eslint.config.js`: flat config with `@eslint/js`, `typescript-eslint`, React hooks, import sort, check-file, vitest rules scoped to test files, `eslint-config-prettier` last.
|
|
14
14
|
- `src/test/setup.ts`: `@testing-library/jest-dom` import, `cleanup` after each test.
|
|
15
|
-
- `e2e/screenshot.ts`: capture template. A single `CASES` record at the top carries one entry per output file, each naming a section, a theme, a route, and its own viewport, and the loop below writes `screenshots/<section>/<theme>.png
|
|
15
|
+
- `e2e/screenshot.ts`: capture template. A single `CASES` record at the top carries one entry per output file, each naming a section, a theme, a route, and its own viewport, and the loop below writes `screenshots/<hostname>/<section>/<theme>.png`, keyed on `SCREENSHOT_BASE_URL`'s hostname so a local and a deployed run land in different folders. Per-project cases extend the one record. A route's themes sit together under its section folder, so the filename carries the theme alone. `--check-console-clean` collects `console`-level error messages per case and exits 1 with the list if any fired, turning the capture into a smoke check. `--require-base-url` exits 1 before launching a browser when `SCREENSHOT_BASE_URL` is unset, guarding a script meant to run against a real deployment from silently capturing `localhost`.
|
|
16
16
|
- `.vscode/extensions.json` and `.vscode/settings.json`: editor wiring for ESLint, Tailwind, Playwright, Vitest.
|
|
17
17
|
- `.github/workflows/verify.yml`: `static-checks`, `unit-tests`, `build-verify`, and `e2e-tests` jobs.
|
|
18
18
|
- `scripts/verify.sh`: extends base verify with typecheck, lint, unit tests, and build in the full order.
|
|
@@ -106,11 +106,20 @@ Append rows:
|
|
|
106
106
|
| `bun run test:e2e` | Run Playwright E2E tests. |
|
|
107
107
|
| `bun run test:e2e:changed` | Run Playwright E2E tests for specs the import graph reaches from the current diff. |
|
|
108
108
|
| `bun run screenshot` | Build, preview, then capture screenshots. |
|
|
109
|
+
| `bun run smoke:prod` | Capture against `SCREENSHOT_BASE_URL`, requiring it set and failing on any console error. |
|
|
109
110
|
|
|
110
111
|
`canon tooling verify <stack>` is the only automated caller of `bun run screenshot`, running it for any stack whose `package.json` declares the script and asserting that PNG files land under `screenshots/`. It counts them with a recursive find carrying no depth limit, so the section folders the seed writes satisfy the assertion without a change to it. Do not flatten the layout to protect that check. No ship chain captures a screenshot, so the output path the seed writes is a contract that one verifier reads rather than a default a ship step depends on.
|
|
111
112
|
|
|
112
113
|
`governance/rules/ui/440-surface-capture.md` is what asks a session to run the capture after a route changes. It fires on route and page files rather than on every component, so a shared component changing every screen fires nothing and the operator runs the capture by hand.
|
|
113
114
|
|
|
115
|
+
The screenshot output now tracks in git rather than getting discarded, so the first capture a scaffolded target runs after this change is the baseline it commits.
|
|
116
|
+
|
|
117
|
+
## Gitignore (extend)
|
|
118
|
+
|
|
119
|
+
`[gitignore]` groups this stack edits, restated here per the manifest-to-reference symmetry:
|
|
120
|
+
|
|
121
|
+
- `"# Playwright" = ["test-results/", "playwright-report/", "blob-report/", "playwright/.cache/"]`
|
|
122
|
+
|
|
114
123
|
## Verify script
|
|
115
124
|
|
|
116
125
|
The web layer's `scripts/verify.sh` replaces the base version. Order: typecheck, lint, format, spelling, shell, unit tests, build. Stack adapters may override if their typecheck or build differs.
|