@hecer/yoke 0.2.0 → 0.7.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/CHANGELOG.md +112 -0
- package/README.md +557 -494
- package/bench/.runs/claude-2026-07-09T22-34-01/.yoke/config.yaml +6 -0
- package/bench/.runs/claude-2026-07-09T22-34-01/.yoke/context/DECISIONS.md +9 -0
- package/bench/.runs/claude-2026-07-09T22-34-01/.yoke/prd.yaml +38 -0
- package/bench/.runs/claude-2026-07-09T22-34-01/bench-verify.mjs +15 -0
- package/bench/.runs/claude-2026-07-09T22-34-01/package.json +9 -0
- package/bench/.runs/claude-2026-07-09T22-34-01/src/index.mjs +48 -0
- package/bench/.runs/claude-2026-07-09T22-34-01/tests/STORY-1.test.mjs +24 -0
- package/bench/.runs/claude-2026-07-09T22-34-01/tests/STORY-2.test.mjs +28 -0
- package/bench/.runs/claude-2026-07-09T22-34-01/tests/STORY-3.test.mjs +25 -0
- package/bench/.runs/gemini-2026-07-09T22-34-02/.yoke/config.yaml +6 -0
- package/bench/.runs/gemini-2026-07-09T22-34-02/.yoke/prd.yaml +32 -0
- package/bench/.runs/gemini-2026-07-09T22-34-02/bench-verify.mjs +15 -0
- package/bench/.runs/gemini-2026-07-09T22-34-02/package.json +9 -0
- package/bench/.runs/gemini-2026-07-09T22-34-02/src/index.mjs +3 -0
- package/bench/.runs/gemini-2026-07-09T22-34-02/tests/STORY-1.test.mjs +24 -0
- package/bench/.runs/gemini-2026-07-09T22-34-02/tests/STORY-2.test.mjs +28 -0
- package/bench/.runs/gemini-2026-07-09T22-34-02/tests/STORY-3.test.mjs +25 -0
- package/bench/README.md +42 -0
- package/bench/RESULTS.md +36 -0
- package/bench/fixtures/string-kit/.yoke/config.yaml +6 -0
- package/bench/fixtures/string-kit/.yoke/prd.yaml +32 -0
- package/bench/fixtures/string-kit/bench-verify.mjs +15 -0
- package/bench/fixtures/string-kit/package.json +9 -0
- package/bench/fixtures/string-kit/src/index.mjs +3 -0
- package/bench/fixtures/string-kit/tests/STORY-1.test.mjs +24 -0
- package/bench/fixtures/string-kit/tests/STORY-2.test.mjs +28 -0
- package/bench/fixtures/string-kit/tests/STORY-3.test.mjs +25 -0
- package/bench/results/claude-2026-07-09T22-34-01.json +40 -0
- package/bench/run.mjs +115 -0
- package/canon/loop/loop-spec.md +5 -1
- package/canon/manifest.yaml +3 -0
- package/canon/skills/yoke-retrofit/SKILL.md +1 -0
- package/canon/tools/claude-mem.md +15 -0
- package/canon/tools/ui-ux-pro-max.md +15 -0
- package/dist/cli.js +32 -6
- package/dist/loop/cleanup.js +44 -3
- package/dist/loop/loop.js +27 -2
- package/dist/loop/reporter.js +21 -3
- package/dist/loop/run-command.js +19 -6
- package/dist/loop/runner.js +130 -13
- package/dist/loop/watchdog.js +67 -17
- package/dist/retrofit/apply.js +10 -3
- package/dist/retrofit/config.js +2 -0
- package/dist/retrofit/gitignore.js +2 -0
- package/dist/retrofit/planners/claude.js +4 -1
- package/dist/retrofit/planners/gemini.js +2 -1
- package/dist/retrofit/preserve.js +51 -0
- package/dist/update/check.js +86 -0
- package/dist/update/refresh.js +28 -0
- package/dist/update/upgrade.js +30 -0
- package/docs/PUBLISHING.md +41 -0
- package/docs/superpowers/plans/2026-06-27-forge-a-canon.md +815 -0
- package/docs/superpowers/plans/2026-06-27-forge-b1-retrofit-claude.md +842 -0
- package/docs/superpowers/plans/2026-06-27-forge-b2-codex-gemini-tools.md +884 -0
- package/docs/superpowers/plans/2026-06-27-forge-c1-loop-engine.md +891 -0
- package/docs/superpowers/plans/2026-06-28-baustein-e-context-layer.md +981 -0
- package/docs/superpowers/plans/2026-06-28-forge-b3-settings-merge.md +364 -0
- package/docs/superpowers/plans/2026-06-28-forge-c2-loop-verify.md +502 -0
- package/docs/superpowers/plans/2026-06-28-forge-c3-multi-agent-runners.md +377 -0
- package/docs/superpowers/plans/2026-06-28-forge-c4-worktree-isolation.md +413 -0
- package/docs/superpowers/plans/2026-06-28-forge-c5-review-iteration.md +419 -0
- package/docs/superpowers/plans/2026-06-28-forge-cleanup-dep0190.md +233 -0
- package/docs/superpowers/plans/2026-06-28-forge-d-codegraph-choice-minimal-code.md +523 -0
- package/docs/superpowers/plans/2026-06-29-baustein-f-routing.md +258 -0
- package/docs/superpowers/plans/2026-06-29-baustein-g-loop-observability.md +1006 -0
- package/docs/superpowers/plans/2026-06-29-baustein-h-loop-robustness.md +374 -0
- package/docs/superpowers/plans/2026-06-30-baustein-i-visual-design-verification.md +450 -0
- package/docs/superpowers/plans/2026-07-02-baustein-j-cross-model-review-gstack-compose.md +645 -0
- package/docs/superpowers/plans/2026-07-02-baustein-k-zero-to-100-bootstrap.md +1024 -0
- package/docs/superpowers/plans/2026-07-02-baustein-m-flow-smoke-proofs.md +574 -0
- package/docs/superpowers/specs/2026-06-27-forge-cross-agent-harness-design.md +144 -0
- package/docs/superpowers/specs/2026-06-28-baustein-e-context-layer-design.md +146 -0
- package/docs/superpowers/specs/2026-06-29-baustein-f-routing-design.md +106 -0
- package/docs/superpowers/specs/2026-06-29-baustein-g-loop-observability-design.md +186 -0
- package/docs/superpowers/specs/2026-06-29-baustein-h-loop-robustness-design.md +113 -0
- package/docs/superpowers/specs/2026-06-30-baustein-i-visual-design-verification-design.md +98 -0
- package/docs/superpowers/specs/2026-07-02-baustein-j-cross-model-review-gstack-compose-design.md +137 -0
- package/docs/superpowers/specs/2026-07-02-baustein-k-zero-to-100-bootstrap-design.md +200 -0
- package/docs/superpowers/specs/2026-07-02-baustein-m-flow-smoke-proofs-design.md +155 -0
- package/docs/superpowers/specs/2026-07-10-companion-tools-decision.md +33 -0
- package/docs/superpowers/specs/2026-07-10-multi-agent-parallel-loop-design.md +99 -0
- package/package.json +8 -2
|
@@ -0,0 +1,574 @@
|
|
|
1
|
+
# Baustein M — Flow-Smoke with Proofs Implementation Plan
|
|
2
|
+
|
|
3
|
+
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
4
|
+
|
|
5
|
+
**Goal:** `yoke flow-smoke` — a built-in browser gate that loads configured flows, asserts landmark + zero console errors, always saves screenshots to `.yoke/proof/<label>/`, keeps video only on failure; the loop labels proofs per story via `YOKE_STORY`.
|
|
6
|
+
|
|
7
|
+
**Architecture:** One async command module (`src/smoke/command.ts`) with a structural `SmokeBrowser` interface; the real path adapts Playwright resolved **from the target project** (never a Yoke dependency); tests inject a filesystem-level fake. Config gains an optional `smoke` section. `main()` in cli.ts becomes awaitable.
|
|
8
|
+
|
|
9
|
+
**Tech Stack:** Node 20 ESM TypeScript (`.js` import specifiers!), vitest, zod, yaml. No new dependencies (Playwright is a *target-project* dependency).
|
|
10
|
+
|
|
11
|
+
**Spec:** `docs/superpowers/specs/2026-07-02-baustein-m-flow-smoke-proofs-design.md`
|
|
12
|
+
|
|
13
|
+
**Pitfalls for the implementer:**
|
|
14
|
+
- rtk proxy mangles grep output — verify test results ONLY via `npx vitest run --reporter=json --outputFile=.yoke-test.json` + node parse; delete the file before committing. Conda `pydantic_core` stderr banner = harmless noise.
|
|
15
|
+
- `npx tsc --noEmit` before every commit. ESM `.js` import specifiers.
|
|
16
|
+
- **Planned deviation from the spec:** the spec's `opts.browser?: () => Promise<SmokeBrowser>` seam is generalized to `opts.launch?: (targetDir: string) => Promise<SmokeBrowser | null>` (null = "playwright unresolvable"). This makes the exit-2 path deterministically testable without depending on ambient playwright resolution on the dev machine. Record this in your final report.
|
|
17
|
+
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
### Task 1: `smoke` config section
|
|
21
|
+
|
|
22
|
+
**Files:**
|
|
23
|
+
- Modify: `src/retrofit/config.ts`
|
|
24
|
+
- Test: `tests/retrofit/config.test.ts` (extend; if that exact file doesn't exist, extend the existing test file that covers `loadConfig`/`saveConfig` — find it with Glob `tests/retrofit/*config*`)
|
|
25
|
+
|
|
26
|
+
- [ ] **Step 1: Write the failing tests** (append to the config test file, reusing its temp-dir helpers):
|
|
27
|
+
|
|
28
|
+
```ts
|
|
29
|
+
it('round-trips a smoke section', () => {
|
|
30
|
+
const config = { ...defaultConfig('1.0.0'), smoke: { baseUrl: 'http://localhost:3000', flows: [{ name: 'home', path: '/', landmark: 'main h1' }, { name: 'login', path: '/login' }] } }
|
|
31
|
+
saveConfig(dir, config)
|
|
32
|
+
expect(loadConfig(dir)?.smoke?.flows).toHaveLength(2)
|
|
33
|
+
expect(loadConfig(dir)?.smoke?.flows[0].landmark).toBe('main h1')
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('config without smoke stays valid', () => {
|
|
37
|
+
saveConfig(dir, defaultConfig('1.0.0'))
|
|
38
|
+
expect(loadConfig(dir)?.smoke).toBeUndefined()
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('rejects a smoke section with empty flows', () => {
|
|
42
|
+
writeFileSync(join(dir, '.yoke', 'config.yaml'), 'canonVersion: "1"\nagents: []\nloop:\n enabled: false\nsmoke:\n baseUrl: http://x\n flows: []\n')
|
|
43
|
+
expect(() => loadConfig(dir)).toThrow()
|
|
44
|
+
})
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
- [ ] **Step 2: Run to verify failure.**
|
|
48
|
+
|
|
49
|
+
- [ ] **Step 3: Implement** in `src/retrofit/config.ts` — above `YokeConfigSchema`:
|
|
50
|
+
|
|
51
|
+
```ts
|
|
52
|
+
const SmokeFlowSchema = z.object({ name: z.string().min(1), path: z.string().min(1), landmark: z.string().optional() })
|
|
53
|
+
const SmokeSchema = z.object({ baseUrl: z.string().min(1), flows: z.array(SmokeFlowSchema).min(1) })
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Add `smoke: SmokeSchema.optional(),` to `YokeConfigSchema` and the interfaces:
|
|
57
|
+
|
|
58
|
+
```ts
|
|
59
|
+
export interface SmokeFlow { name: string; path: string; landmark?: string }
|
|
60
|
+
export interface SmokeConfig { baseUrl: string; flows: SmokeFlow[] }
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
plus `smoke?: SmokeConfig` on `YokeConfig`.
|
|
64
|
+
|
|
65
|
+
- [ ] **Step 4: Run** — config tests PASS, `npx tsc --noEmit` clean.
|
|
66
|
+
|
|
67
|
+
- [ ] **Step 5: Commit** — `feat(config): optional smoke section (baseUrl + flows)`
|
|
68
|
+
|
|
69
|
+
---
|
|
70
|
+
|
|
71
|
+
### Task 2: `runFlowSmoke` core with fake browser
|
|
72
|
+
|
|
73
|
+
**Files:**
|
|
74
|
+
- Create: `src/smoke/command.ts`
|
|
75
|
+
- Test: `tests/smoke/command.test.ts`
|
|
76
|
+
|
|
77
|
+
- [ ] **Step 1: Write the failing tests:**
|
|
78
|
+
|
|
79
|
+
```ts
|
|
80
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
81
|
+
import { mkdtempSync, rmSync, mkdirSync, writeFileSync, existsSync, readdirSync } from 'node:fs'
|
|
82
|
+
import { tmpdir } from 'node:os'
|
|
83
|
+
import { dirname, join } from 'node:path'
|
|
84
|
+
import { runFlowSmoke, type SmokeBrowser, type SmokePage } from '../../src/smoke/command.js'
|
|
85
|
+
import { saveConfig, defaultConfig, type SmokeConfig } from '../../src/retrofit/config.js'
|
|
86
|
+
|
|
87
|
+
let dir: string
|
|
88
|
+
beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'yoke-smoke-')) })
|
|
89
|
+
afterEach(() => {
|
|
90
|
+
rmSync(dir, { recursive: true, force: true })
|
|
91
|
+
delete process.env.YOKE_STORY
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
function withSmoke(smoke: SmokeConfig) {
|
|
95
|
+
saveConfig(dir, { ...defaultConfig('1.0.0'), smoke })
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
interface FakeBehavior {
|
|
99
|
+
status?: number // default 200
|
|
100
|
+
landmarkFound?: boolean // default true
|
|
101
|
+
consoleErrors?: string[] // default []
|
|
102
|
+
gotoThrows?: string
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
let vid = 0
|
|
106
|
+
function fakeLaunch(behavior: FakeBehavior): (targetDir: string) => Promise<SmokeBrowser | null> {
|
|
107
|
+
return async () => ({
|
|
108
|
+
async newContext(opts?: { recordVideo?: { dir: string } }) {
|
|
109
|
+
const videoDir = opts?.recordVideo?.dir
|
|
110
|
+
let videoPath: string | null = null
|
|
111
|
+
if (videoDir) {
|
|
112
|
+
mkdirSync(videoDir, { recursive: true })
|
|
113
|
+
videoPath = join(videoDir, `v${vid++}.webm`)
|
|
114
|
+
writeFileSync(videoPath, 'vid')
|
|
115
|
+
}
|
|
116
|
+
const handlers: Record<string, ((a: unknown) => void)[]> = { console: [], pageerror: [] }
|
|
117
|
+
const page: SmokePage = {
|
|
118
|
+
async goto() {
|
|
119
|
+
if (behavior.gotoThrows) throw new Error(behavior.gotoThrows)
|
|
120
|
+
for (const e of behavior.consoleErrors ?? []) {
|
|
121
|
+
for (const h of handlers.console) h({ type: () => 'error', text: () => e })
|
|
122
|
+
}
|
|
123
|
+
const status = behavior.status ?? 200
|
|
124
|
+
return { ok: () => status >= 200 && status < 300, status: () => status }
|
|
125
|
+
},
|
|
126
|
+
async waitForSelector() {
|
|
127
|
+
if (behavior.landmarkFound === false) throw new Error('timeout')
|
|
128
|
+
return {}
|
|
129
|
+
},
|
|
130
|
+
async screenshot({ path }: { path: string }) {
|
|
131
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
132
|
+
writeFileSync(path, 'png')
|
|
133
|
+
},
|
|
134
|
+
on(event: 'console' | 'pageerror', handler: (a: unknown) => void) { handlers[event].push(handler) },
|
|
135
|
+
video: () => (videoPath ? { path: async () => videoPath as string } : null),
|
|
136
|
+
}
|
|
137
|
+
return { newPage: async () => page, close: async () => {} }
|
|
138
|
+
},
|
|
139
|
+
async close() {},
|
|
140
|
+
})
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const HOME = { name: 'home', path: '/', landmark: 'main h1' }
|
|
144
|
+
|
|
145
|
+
describe('runFlowSmoke', () => {
|
|
146
|
+
it('exits 2 with guidance when there is no smoke config', async () => {
|
|
147
|
+
saveConfig(dir, defaultConfig('1.0.0'))
|
|
148
|
+
expect(await runFlowSmoke(dir, { launch: fakeLaunch({}) })).toBe(2)
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it('exits 2 when playwright cannot be resolved (launch returns null)', async () => {
|
|
152
|
+
withSmoke({ baseUrl: 'http://x', flows: [HOME] })
|
|
153
|
+
expect(await runFlowSmoke(dir, { launch: async () => null })).toBe(2)
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it('green flow: exit 0, screenshot saved, video deleted', async () => {
|
|
157
|
+
withSmoke({ baseUrl: 'http://x', flows: [HOME] })
|
|
158
|
+
const code = await runFlowSmoke(dir, { launch: fakeLaunch({}) })
|
|
159
|
+
expect(code).toBe(0)
|
|
160
|
+
const proof = join(dir, '.yoke', 'proof', 'latest')
|
|
161
|
+
expect(existsSync(join(proof, 'home.png'))).toBe(true)
|
|
162
|
+
expect(readdirSync(proof).some(f => f.endsWith('.webm'))).toBe(false)
|
|
163
|
+
expect(existsSync(join(proof, '.video-tmp'))).toBe(false)
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
it('landmark timeout: exit 1, screenshot still saved, video kept', async () => {
|
|
167
|
+
withSmoke({ baseUrl: 'http://x', flows: [HOME] })
|
|
168
|
+
const code = await runFlowSmoke(dir, { launch: fakeLaunch({ landmarkFound: false }) })
|
|
169
|
+
expect(code).toBe(1)
|
|
170
|
+
const proof = join(dir, '.yoke', 'proof', 'latest')
|
|
171
|
+
expect(existsSync(join(proof, 'home.png'))).toBe(true)
|
|
172
|
+
expect(existsSync(join(proof, 'home.webm'))).toBe(true)
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
it('console errors fail the flow', async () => {
|
|
176
|
+
withSmoke({ baseUrl: 'http://x', flows: [HOME] })
|
|
177
|
+
expect(await runFlowSmoke(dir, { launch: fakeLaunch({ consoleErrors: ['boom'] }) })).toBe(1)
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
it('a non-OK response fails the flow', async () => {
|
|
181
|
+
withSmoke({ baseUrl: 'http://x', flows: [HOME] })
|
|
182
|
+
expect(await runFlowSmoke(dir, { launch: fakeLaunch({ status: 500 }) })).toBe(1)
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
it('a goto crash fails the flow but still screenshots', async () => {
|
|
186
|
+
withSmoke({ baseUrl: 'http://x', flows: [HOME] })
|
|
187
|
+
const code = await runFlowSmoke(dir, { launch: fakeLaunch({ gotoThrows: 'net::ERR_CONNECTION_REFUSED' }) })
|
|
188
|
+
expect(code).toBe(1)
|
|
189
|
+
expect(existsSync(join(dir, '.yoke', 'proof', 'latest', 'home.png'))).toBe(true)
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
it('label resolution: --label beats YOKE_STORY beats latest', async () => {
|
|
193
|
+
withSmoke({ baseUrl: 'http://x', flows: [{ name: 'home', path: '/' }] })
|
|
194
|
+
process.env.YOKE_STORY = 'S7'
|
|
195
|
+
await runFlowSmoke(dir, { launch: fakeLaunch({}) })
|
|
196
|
+
expect(existsSync(join(dir, '.yoke', 'proof', 'S7', 'home.png'))).toBe(true)
|
|
197
|
+
await runFlowSmoke(dir, { launch: fakeLaunch({}), label: 'manual' })
|
|
198
|
+
expect(existsSync(join(dir, '.yoke', 'proof', 'manual', 'home.png'))).toBe(true)
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
it('wipes the label dir before a run', async () => {
|
|
202
|
+
withSmoke({ baseUrl: 'http://x', flows: [{ name: 'home', path: '/' }] })
|
|
203
|
+
const proof = join(dir, '.yoke', 'proof', 'latest')
|
|
204
|
+
mkdirSync(proof, { recursive: true })
|
|
205
|
+
writeFileSync(join(proof, 'stale.png'), 'old')
|
|
206
|
+
await runFlowSmoke(dir, { launch: fakeLaunch({}) })
|
|
207
|
+
expect(existsSync(join(proof, 'stale.png'))).toBe(false)
|
|
208
|
+
expect(existsSync(join(proof, 'home.png'))).toBe(true)
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
it('a failing flow does not stop later flows', async () => {
|
|
212
|
+
withSmoke({ baseUrl: 'http://x', flows: [HOME, { name: 'about', path: '/about' }] })
|
|
213
|
+
// landmarkFound:false only affects flows WITH a landmark — about has none and passes
|
|
214
|
+
const code = await runFlowSmoke(dir, { launch: fakeLaunch({ landmarkFound: false }) })
|
|
215
|
+
expect(code).toBe(1)
|
|
216
|
+
const proof = join(dir, '.yoke', 'proof', 'latest')
|
|
217
|
+
expect(existsSync(join(proof, 'home.png'))).toBe(true)
|
|
218
|
+
expect(existsSync(join(proof, 'about.png'))).toBe(true)
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
it('--url overrides baseUrl (fake records the target url)', async () => {
|
|
222
|
+
withSmoke({ baseUrl: 'http://x', flows: [{ name: 'home', path: '/p' }] })
|
|
223
|
+
const seen: string[] = []
|
|
224
|
+
const launch = fakeLaunch({})
|
|
225
|
+
const spying: typeof launch = async (t) => {
|
|
226
|
+
const b = await launch(t)
|
|
227
|
+
if (!b) return null
|
|
228
|
+
const orig = b.newContext.bind(b)
|
|
229
|
+
b.newContext = async (o?: object) => {
|
|
230
|
+
const ctx = await orig(o)
|
|
231
|
+
const origPage = ctx.newPage.bind(ctx)
|
|
232
|
+
ctx.newPage = async () => {
|
|
233
|
+
const p = await origPage()
|
|
234
|
+
const g = p.goto.bind(p)
|
|
235
|
+
p.goto = async (url: string, o2?: object) => { seen.push(url); return g(url, o2) }
|
|
236
|
+
return p
|
|
237
|
+
}
|
|
238
|
+
return ctx
|
|
239
|
+
}
|
|
240
|
+
return b
|
|
241
|
+
}
|
|
242
|
+
await runFlowSmoke(dir, { launch: spying, url: 'http://override:9999' })
|
|
243
|
+
expect(seen[0]).toBe('http://override:9999/p')
|
|
244
|
+
})
|
|
245
|
+
})
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
- [ ] **Step 2: Run to verify failure** — module not found.
|
|
249
|
+
|
|
250
|
+
- [ ] **Step 3: Implement `src/smoke/command.ts`:**
|
|
251
|
+
|
|
252
|
+
```ts
|
|
253
|
+
import { existsSync, mkdirSync, rmSync, renameSync } from 'node:fs'
|
|
254
|
+
import { join } from 'node:path'
|
|
255
|
+
import { createRequire } from 'node:module'
|
|
256
|
+
import { pathToFileURL } from 'node:url'
|
|
257
|
+
import { loadConfig } from '../retrofit/config.js'
|
|
258
|
+
|
|
259
|
+
// Structural browser interface: the real path adapts Playwright's chromium,
|
|
260
|
+
// tests inject a filesystem-level fake. Playwright is a TARGET-project
|
|
261
|
+
// dependency, never Yoke's.
|
|
262
|
+
export interface SmokePage {
|
|
263
|
+
goto(url: string, opts?: object): Promise<{ ok(): boolean; status(): number } | null>
|
|
264
|
+
waitForSelector(sel: string, opts?: object): Promise<unknown>
|
|
265
|
+
screenshot(opts: { path: string; fullPage?: boolean }): Promise<unknown>
|
|
266
|
+
on(event: 'console' | 'pageerror', handler: (arg: unknown) => void): void
|
|
267
|
+
video(): { path(): Promise<string> } | null
|
|
268
|
+
}
|
|
269
|
+
export interface SmokeContext { newPage(): Promise<SmokePage>; close(): Promise<void> }
|
|
270
|
+
export interface SmokeBrowser {
|
|
271
|
+
newContext(opts?: object): Promise<SmokeContext>
|
|
272
|
+
close(): Promise<void>
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export interface FlowSmokeOptions {
|
|
276
|
+
url?: string
|
|
277
|
+
label?: string
|
|
278
|
+
// null = playwright unresolvable in the target project (exit 2)
|
|
279
|
+
launch?: (targetDir: string) => Promise<SmokeBrowser | null>
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const CONFIG_GUIDANCE = [
|
|
283
|
+
'No smoke flows configured. Add a smoke section to .yoke/config.yaml, e.g.:',
|
|
284
|
+
'',
|
|
285
|
+
'smoke:',
|
|
286
|
+
' baseUrl: http://localhost:3000',
|
|
287
|
+
' flows:',
|
|
288
|
+
' - name: home',
|
|
289
|
+
' path: /',
|
|
290
|
+
' landmark: "main h1"',
|
|
291
|
+
].join('\n')
|
|
292
|
+
|
|
293
|
+
async function launchPlaywright(targetDir: string): Promise<SmokeBrowser | null> {
|
|
294
|
+
try {
|
|
295
|
+
const req = createRequire(join(targetDir, 'package.json'))
|
|
296
|
+
const resolved = req.resolve('playwright')
|
|
297
|
+
const pw = await import(pathToFileURL(resolved).href) as { chromium?: { launch(o: object): Promise<SmokeBrowser> }; default?: { chromium: { launch(o: object): Promise<SmokeBrowser> } } }
|
|
298
|
+
const chromium = pw.chromium ?? pw.default?.chromium
|
|
299
|
+
if (!chromium) return null
|
|
300
|
+
return await chromium.launch({ headless: true })
|
|
301
|
+
} catch {
|
|
302
|
+
return null
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Flow names come from user config and become filenames — keep them safe.
|
|
307
|
+
function safeName(name: string): string {
|
|
308
|
+
return name.replace(/[^\w.-]+/g, '-')
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
export async function runFlowSmoke(targetDir: string, opts: FlowSmokeOptions = {}): Promise<number> {
|
|
312
|
+
const config = loadConfig(targetDir)
|
|
313
|
+
const smoke = config?.smoke
|
|
314
|
+
if (!smoke) {
|
|
315
|
+
console.error(CONFIG_GUIDANCE)
|
|
316
|
+
return 2
|
|
317
|
+
}
|
|
318
|
+
const baseUrl = opts.url ?? smoke.baseUrl
|
|
319
|
+
const label = opts.label ?? process.env.YOKE_STORY ?? 'latest'
|
|
320
|
+
const proofRel = join('.yoke', 'proof', label)
|
|
321
|
+
const proofDir = join(targetDir, proofRel)
|
|
322
|
+
rmSync(proofDir, { recursive: true, force: true }) // fresh evidence per run
|
|
323
|
+
mkdirSync(proofDir, { recursive: true })
|
|
324
|
+
const videoTmp = join(proofDir, '.video-tmp')
|
|
325
|
+
|
|
326
|
+
const launch = opts.launch ?? launchPlaywright
|
|
327
|
+
const browser = await launch(targetDir)
|
|
328
|
+
if (!browser) {
|
|
329
|
+
console.error(`Playwright not found in ${targetDir}. Install it: npm i -D playwright && npx playwright install chromium`)
|
|
330
|
+
return 2
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
let green = 0
|
|
334
|
+
try {
|
|
335
|
+
for (const flow of smoke.flows) {
|
|
336
|
+
const context = await browser.newContext({ recordVideo: { dir: videoTmp } })
|
|
337
|
+
const page = await context.newPage()
|
|
338
|
+
const errors: string[] = []
|
|
339
|
+
page.on('console', (msg) => {
|
|
340
|
+
const m = msg as { type?: () => string; text?: () => string }
|
|
341
|
+
if (m.type?.() === 'error') errors.push(String(m.text?.() ?? msg))
|
|
342
|
+
})
|
|
343
|
+
page.on('pageerror', (err) => {
|
|
344
|
+
errors.push(String((err as Error)?.message ?? err))
|
|
345
|
+
})
|
|
346
|
+
let reason: string | undefined
|
|
347
|
+
try {
|
|
348
|
+
const resp = await page.goto(baseUrl + flow.path, { waitUntil: 'load', timeout: 30_000 })
|
|
349
|
+
if (resp && !resp.ok()) reason = `HTTP ${resp.status()}`
|
|
350
|
+
if (!reason && flow.landmark) {
|
|
351
|
+
try {
|
|
352
|
+
await page.waitForSelector(flow.landmark, { timeout: 10_000 })
|
|
353
|
+
} catch {
|
|
354
|
+
reason = `landmark "${flow.landmark}" not found`
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
if (!reason && errors.length > 0) {
|
|
358
|
+
reason = `${errors.length} console error(s): ${errors[0].slice(0, 200)}`
|
|
359
|
+
}
|
|
360
|
+
} catch (e) {
|
|
361
|
+
reason = (e as Error).message.split('\n')[0]
|
|
362
|
+
}
|
|
363
|
+
// The screenshot IS the evidence — taken on success AND failure; a crashed
|
|
364
|
+
// page must not mask the original failure.
|
|
365
|
+
const shotName = `${safeName(flow.name)}.png`
|
|
366
|
+
try {
|
|
367
|
+
await page.screenshot({ path: join(proofDir, shotName), fullPage: true })
|
|
368
|
+
} catch { /* keep the original reason */ }
|
|
369
|
+
const video = page.video()
|
|
370
|
+
await context.close()
|
|
371
|
+
if (video) {
|
|
372
|
+
try {
|
|
373
|
+
const vpath = await video.path()
|
|
374
|
+
if (reason) renameSync(vpath, join(proofDir, `${safeName(flow.name)}.webm`))
|
|
375
|
+
else rmSync(vpath, { force: true })
|
|
376
|
+
} catch { /* video is best-effort evidence */ }
|
|
377
|
+
}
|
|
378
|
+
if (reason) {
|
|
379
|
+
console.log(`✘ ${flow.name} — ${reason} (screenshot + video saved under ${proofRel})`)
|
|
380
|
+
} else {
|
|
381
|
+
green++
|
|
382
|
+
console.log(`✔ ${flow.name} (screenshot: ${join(proofRel, shotName)})`)
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
} finally {
|
|
386
|
+
await browser.close()
|
|
387
|
+
rmSync(videoTmp, { recursive: true, force: true })
|
|
388
|
+
}
|
|
389
|
+
console.log(`Flow-smoke: ${green}/${smoke.flows.length} flows green — proof: ${proofRel}`)
|
|
390
|
+
return green === smoke.flows.length ? 0 : 1
|
|
391
|
+
}
|
|
392
|
+
```
|
|
393
|
+
|
|
394
|
+
- [ ] **Step 4: Run** — `npx vitest run tests/smoke` → PASS. `npx tsc --noEmit` clean.
|
|
395
|
+
|
|
396
|
+
- [ ] **Step 5: Commit** — `feat(smoke): yoke flow-smoke core — screenshots always, video on failure`
|
|
397
|
+
|
|
398
|
+
---
|
|
399
|
+
|
|
400
|
+
### Task 3: Loop linkage (`YOKE_STORY`) + gitignore `.yoke/proof/`
|
|
401
|
+
|
|
402
|
+
**Files:**
|
|
403
|
+
- Modify: `src/loop/loop.ts` (both verify call sites, near lines 88 and 138)
|
|
404
|
+
- Modify: `src/retrofit/gitignore.ts`
|
|
405
|
+
- Test: the existing loop test file that exercises `runLoop` with a fake verifier (find with Glob `tests/loop/loop*.test.ts`); `tests/retrofit/gitignore.test.ts`
|
|
406
|
+
|
|
407
|
+
- [ ] **Step 1: Write the failing tests.** In the loop test file, using its existing fake-runner/fake-git helpers:
|
|
408
|
+
|
|
409
|
+
```ts
|
|
410
|
+
it('exposes the story id to the verifier via YOKE_STORY and unsets it after', () => {
|
|
411
|
+
let seen: string | undefined
|
|
412
|
+
const verify = (dir: string) => { seen = process.env.YOKE_STORY; return { passed: true, summary: 'ok' } }
|
|
413
|
+
// arrange a single-story PRD exactly like the existing happy-path test, then runLoop with this verifier
|
|
414
|
+
// ... existing setup ...
|
|
415
|
+
expect(seen).toBe('S1')
|
|
416
|
+
expect(process.env.YOKE_STORY).toBeUndefined()
|
|
417
|
+
})
|
|
418
|
+
```
|
|
419
|
+
|
|
420
|
+
In `tests/retrofit/gitignore.test.ts`: extend the explicit-lines assertion so `.yoke/proof/` is one of the ensured lines (same pattern as the `.yoke/loop.lock` case from Baustein K).
|
|
421
|
+
|
|
422
|
+
- [ ] **Step 2: Run to verify failure.**
|
|
423
|
+
|
|
424
|
+
- [ ] **Step 3: Implement.** In `src/retrofit/gitignore.ts`, append `'.yoke/proof/'` to `YOKE_IGNORE_LINES`. In `src/loop/loop.ts`, wrap BOTH verify call sites:
|
|
425
|
+
|
|
426
|
+
```ts
|
|
427
|
+
reporter.phase('verifying')
|
|
428
|
+
process.env.YOKE_STORY = story.id
|
|
429
|
+
let verdict
|
|
430
|
+
try {
|
|
431
|
+
verdict = opts.verify(wt) // second site: opts.verify(opts.targetDir)
|
|
432
|
+
} finally {
|
|
433
|
+
delete process.env.YOKE_STORY
|
|
434
|
+
}
|
|
435
|
+
```
|
|
436
|
+
|
|
437
|
+
(Keep everything downstream of `verdict` unchanged.)
|
|
438
|
+
|
|
439
|
+
- [ ] **Step 4: Run** — `npx vitest run tests/loop tests/retrofit` → PASS.
|
|
440
|
+
|
|
441
|
+
- [ ] **Step 5: Commit** — `feat(loop): label flow-smoke proofs per story via YOKE_STORY; gitignore .yoke/proof/`
|
|
442
|
+
|
|
443
|
+
---
|
|
444
|
+
|
|
445
|
+
### Task 4: CLI wiring (async main) + usage
|
|
446
|
+
|
|
447
|
+
**Files:**
|
|
448
|
+
- Modify: `src/cli.ts`
|
|
449
|
+
|
|
450
|
+
- [ ] **Step 1: Make `main` awaitable.** Change the signature to `function main(argv: string[]): number | Promise<number>` and the isMain guard to:
|
|
451
|
+
|
|
452
|
+
```ts
|
|
453
|
+
const isMain = process.argv[1] ? pathToFileURL(process.argv[1]).href === import.meta.url : false
|
|
454
|
+
if (isMain) {
|
|
455
|
+
process.exit(await main(process.argv.slice(2)))
|
|
456
|
+
}
|
|
457
|
+
```
|
|
458
|
+
|
|
459
|
+
(Top-level await is valid here — ESM.) Add the import and the case:
|
|
460
|
+
|
|
461
|
+
```ts
|
|
462
|
+
import { runFlowSmoke } from './smoke/command.js'
|
|
463
|
+
```
|
|
464
|
+
|
|
465
|
+
```ts
|
|
466
|
+
case 'flow-smoke': {
|
|
467
|
+
const targetDir = rest.find(a => !a.startsWith('-')) ?? '.'
|
|
468
|
+
const url = rest.find(a => a.startsWith('--url='))?.slice('--url='.length)
|
|
469
|
+
const label = rest.find(a => a.startsWith('--label='))?.slice('--label='.length)
|
|
470
|
+
return runFlowSmoke(targetDir, { url, label })
|
|
471
|
+
}
|
|
472
|
+
```
|
|
473
|
+
|
|
474
|
+
Extend the `default` usage string with `flow-smoke [dir] [--url=<baseUrl>] [--label=<name>]`.
|
|
475
|
+
|
|
476
|
+
- [ ] **Step 2: Verify** — `npx tsc --noEmit` clean; `npm run build` succeeds; `node dist/cli.js flow-smoke` in the yoke repo itself prints the config guidance and exits 2 (yoke has no smoke section — expected).
|
|
477
|
+
|
|
478
|
+
- [ ] **Step 3: Commit** — `feat(cli): wire yoke flow-smoke (async main)`
|
|
479
|
+
|
|
480
|
+
---
|
|
481
|
+
|
|
482
|
+
### Task 5: Canon skill update + attribution
|
|
483
|
+
|
|
484
|
+
**Files:**
|
|
485
|
+
- Modify: `canon/skills/visual-verification/SKILL.md`
|
|
486
|
+
- Modify: `canon/skills/ATTRIBUTION.md`
|
|
487
|
+
|
|
488
|
+
- [ ] **Step 1: Rewrite sections 2 and 3 of the skill** (keep section 1 and the frontmatter's spirit; update the description to mention the built-in command). New content for the body after section 1:
|
|
489
|
+
|
|
490
|
+
```markdown
|
|
491
|
+
## 2. Flow-smoke with the built-in gate
|
|
492
|
+
|
|
493
|
+
Configure the key user flows once in `.yoke/config.yaml`:
|
|
494
|
+
|
|
495
|
+
```yaml
|
|
496
|
+
smoke:
|
|
497
|
+
baseUrl: http://localhost:3000
|
|
498
|
+
flows:
|
|
499
|
+
- name: home
|
|
500
|
+
path: /
|
|
501
|
+
landmark: "main h1"
|
|
502
|
+
- name: login
|
|
503
|
+
path: /login
|
|
504
|
+
landmark: "form"
|
|
505
|
+
```
|
|
506
|
+
|
|
507
|
+
Then chain the built-in gate in `verify.command`:
|
|
508
|
+
|
|
509
|
+
```
|
|
510
|
+
<typecheck> && <unit tests> && yoke design-scan . && yoke flow-smoke .
|
|
511
|
+
```
|
|
512
|
+
|
|
513
|
+
`yoke flow-smoke` loads each route against the running dev server, waits for the landmark,
|
|
514
|
+
fails on any console error, and **always** saves a screenshot to `.yoke/proof/<story>/`
|
|
515
|
+
(the loop labels the folder with the current story id via `YOKE_STORY`; standalone runs use
|
|
516
|
+
`latest`, or pass `--label=`). Requires Playwright in the project:
|
|
517
|
+
`npm i -D playwright && npx playwright install chromium`. Start the dev server before verify
|
|
518
|
+
(e.g. via `start-server-and-test`).
|
|
519
|
+
|
|
520
|
+
## 3. Video only when necessary
|
|
521
|
+
|
|
522
|
+
`yoke flow-smoke` records video per flow and keeps it **only on failure**
|
|
523
|
+
(`.yoke/proof/<story>/<flow>.webm`). When a flow goes red: watch that clip first, then use the
|
|
524
|
+
wired Playwright MCP to reproduce interactively. Never record every run manually — the gate
|
|
525
|
+
already handles the failure case.
|
|
526
|
+
|
|
527
|
+
## Rule
|
|
528
|
+
|
|
529
|
+
Green pipeline = types + units + no design-slop over budget + every flow renders without
|
|
530
|
+
console errors, with a screenshot to prove it. Only then is the story actually done.
|
|
531
|
+
```
|
|
532
|
+
|
|
533
|
+
Update the frontmatter `description` to: `Use for any UI/web project — make the verify gate cover more than unit tests by composing a pipeline (types → unit → design-scan → flow-smoke) and running the built-in yoke flow-smoke gate (landmark + zero console errors + screenshot proof to .yoke/proof/<story>/, video kept on failure). Catches the unwired-page / runtime-crash / AI-slop bugs unit tests miss.`
|
|
534
|
+
|
|
535
|
+
- [ ] **Step 2: Extend the gstack entry in `canon/skills/ATTRIBUTION.md`** — add to the existing gstack bullet (do not create a second entry): browser-QA-as-gate idea (`/qa`) natively re-implemented as `yoke flow-smoke` with a proof-artifact contract; no code copied.
|
|
536
|
+
|
|
537
|
+
- [ ] **Step 3: Run** — `npx vitest run tests/canon` → PASS (real-canon validates the skill file).
|
|
538
|
+
|
|
539
|
+
- [ ] **Step 4: Commit** — `docs(canon): visual-verification uses built-in flow-smoke; attribution`
|
|
540
|
+
|
|
541
|
+
---
|
|
542
|
+
|
|
543
|
+
### Task 6: README + final verification
|
|
544
|
+
|
|
545
|
+
**Files:**
|
|
546
|
+
- Modify: `README.md`
|
|
547
|
+
|
|
548
|
+
- [ ] **Step 1: README updates** (user rule: README moves with every change):
|
|
549
|
+
- New section **`yoke flow-smoke`** next to the design-scan section: config example, proof contract (screenshots always → `.yoke/proof/<label>/`, video kept on failure, loop labels per story via `YOKE_STORY`), exit codes 0/1/2, Playwright-in-target-project requirement, `--url`/`--label` flags.
|
|
550
|
+
- Extend the verify-pipeline example(s) to `... && yoke design-scan . && yoke flow-smoke .`.
|
|
551
|
+
- Highlights list: add a line about story-level visual proofs ("done, with a photo").
|
|
552
|
+
- Update the test count badge + mentions to the real number from Step 2.
|
|
553
|
+
|
|
554
|
+
- [ ] **Step 2: Full verification:**
|
|
555
|
+
|
|
556
|
+
```
|
|
557
|
+
npx tsc --noEmit
|
|
558
|
+
npx vitest run --reporter=json --outputFile=.yoke-test.json
|
|
559
|
+
node -e "const r=require('./.yoke-test.json'); console.log('success', r.success, 'total', r.numTotalTests, 'failed', r.numFailedTests)"
|
|
560
|
+
npm run build
|
|
561
|
+
node dist/cli.js validate canon
|
|
562
|
+
```
|
|
563
|
+
|
|
564
|
+
Delete `.yoke-test.json`. All green, canon valid.
|
|
565
|
+
|
|
566
|
+
- [ ] **Step 3: Commit** — `docs(readme): flow-smoke gate + proof contract; test count`
|
|
567
|
+
|
|
568
|
+
---
|
|
569
|
+
|
|
570
|
+
## Self-review (done at plan time)
|
|
571
|
+
|
|
572
|
+
- Spec coverage: Part 1→Task 1, Part 2→Task 2, Part 3→Task 3, Part 4→Task 4, Part 5→Task 5, README→Task 6. Spec's `browser` seam intentionally generalized to `launch` (declared deviation, testable exit-2).
|
|
573
|
+
- Placeholder scan: all steps carry complete code or exact edit locations; the two "reuse existing helpers" test steps name the discovery mechanism (Glob patterns) and the exact assertions.
|
|
574
|
+
- Type consistency: `SmokeBrowser`/`SmokeContext`/`SmokePage`/`FlowSmokeOptions.launch` identical across Tasks 2 and the tests; `SmokeConfig`/`SmokeFlow` from Task 1 consumed in Task 2 via `loadConfig`.
|