@invisibleloop/pulse 0.20.11 → 0.20.20
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/package.json +6 -5
- package/public/.pulse-ui-version +1 -1
- package/scripts/build.js +26 -0
- package/scripts/release-version.js +17 -8
- package/scripts/strip-server.test.js +60 -0
- package/src/agent/checklist.md +8 -12
- package/src/agent/commands/pulse-start.md +2 -0
- package/src/agent/commands/verify.md +30 -8
- package/src/agent/guide-components.md +7 -25
- package/src/agent/guide-routing.md +0 -12
- package/src/agent/guide-server.md +102 -1
- package/src/agent/guide-spec.md +8 -1
- package/src/agent/guide-styles.md +23 -0
- package/src/agent/identity.md +9 -0
- package/src/agent/workflow.md +4 -23
- package/src/cli/check-bundles.js +113 -0
- package/src/cli/check-bundles.test.js +137 -0
- package/src/cli/cli.test.js +105 -0
- package/src/cli/dev.js +48 -3
- package/src/cli/diagnose.js +105 -0
- package/src/cli/diagnose.test.js +124 -0
- package/src/cli/discover.js +9 -2
- package/src/cli/index.js +171 -9
- package/src/cli/process-utils.js +57 -0
- package/src/cli/process-utils.test.js +114 -0
- package/src/cli/review.js +391 -0
- package/src/cli/review.test.js +143 -0
- package/src/cli/scaffold.js +9 -4
- package/src/cli/stamp.js +102 -0
- package/src/cli/stamp.test.js +149 -0
- package/src/cli/start.js +15 -2
- package/src/cli/validate.js +184 -0
- package/src/cli/validate.test.js +153 -0
- package/src/mcp/mcp.test.js +261 -30
- package/src/mcp/server.js +207 -515
- package/src/mcp/validate-worker.js +18 -1
- package/src/runtime/index.js +144 -11
- package/src/runtime/runtime.test.js +199 -0
- package/src/runtime/ssr.js +13 -6
- package/src/runtime/ssr.test.js +31 -0
- package/src/server/index.js +162 -9
- package/src/server/server.test.js +225 -2
- package/src/spec/schema.js +1 -1
- package/src/ui/index.js +0 -5
- package/src/ui/ui.test.js +0 -75
- package/types/ui.d.ts +0 -21
- package/.claude/commands/build-page.md +0 -63
- package/.claude/commands/new-doc-page.md +0 -45
- package/.claude/commands/pulse-start.md +0 -11
- package/.claude/commands/pulse-stop.md +0 -5
- package/.claude/commands/verify.md +0 -118
- package/.claude/pulse-checklist.md +0 -189
- package/.github/workflows/ci.yml +0 -25
- package/.github/workflows/publish.yml +0 -45
- package/.pulse/load-reports/home/1773432711417.json +0 -22
- package/.pulse-verified +0 -1
- package/AGENTS.md +0 -697
- package/CLAUDE.md +0 -697
- package/README.md +0 -95
- package/benchmark/public/dist/counter.boot-PO6SNSJZ.js +0 -8
- package/benchmark/public/dist/home.boot-YK6VAEQI.js +0 -6
- package/benchmark/public/dist/manifest.json +0 -5
- package/benchmark/public/dist/runtime-5Y7ESAIB.js +0 -2
- package/benchmark/public/dist/runtime-KO4BHUQ3.js +0 -49
- package/benchmark/results/2026-03-27-09-20.json +0 -188
- package/benchmark/results/2026-03-27-09-21.json +0 -188
- package/benchmark/results/2026-03-27-09-29.json +0 -188
- package/benchmark/results/2026-03-27-10-14.json +0 -188
- package/benchmark/results/2026-03-27-10-18.json +0 -232
- package/benchmark/src/pages/counter.js +0 -37
- package/benchmark/src/pages/home.js +0 -21
- package/docs/.claude/commands/pulse-build.md +0 -7
- package/docs/.claude/commands/pulse-dev.md +0 -7
- package/docs/.claude/commands/pulse-start.md +0 -9
- package/docs/.claude/commands/pulse-stop.md +0 -5
- package/docs/.claude/commands/verify.md +0 -84
- package/docs/.claude/coverage-check.js +0 -196
- package/docs/.claude/pulse-checklist.md +0 -189
- package/docs/public/.pulse-ui-version +0 -1
- package/docs/public/andypixel.css +0 -237
- package/docs/public/bookworms.css +0 -422
- package/docs/public/coutumes.css +0 -561
- package/docs/public/docs.css +0 -2388
- package/docs/public/favicon.svg +0 -4
- package/docs/public/home-brut.css +0 -982
- package/docs/public/images/bookworms/hero-books.jpg +0 -0
- package/docs/public/images/bookworms/how-we-read-now.jpg +0 -0
- package/docs/public/images/bookworms/lantern-keepers.jpg +0 -0
- package/docs/public/images/bookworms/provincial-bookseller.jpg +0 -0
- package/docs/public/images/journey/178.jpg +0 -0
- package/docs/public/images/journey/29.jpg +0 -0
- package/docs/public/images/retrowheels/hero.jpg +0 -0
- package/docs/public/images/shop/devbook-pro.jpg +0 -0
- package/docs/public/images/suzukibento/1015.jpg +0 -0
- package/docs/public/images/suzukibento/1025.jpg +0 -0
- package/docs/public/images/suzukibento/1043.jpg +0 -0
- package/docs/public/images/suzukibento/1059.jpg +0 -0
- package/docs/public/images/suzukibento/1062.jpg +0 -0
- package/docs/public/images/suzukibento/1084.jpg +0 -0
- package/docs/public/images/swish/1469334031218-e382a71b716b.jpg +0 -0
- package/docs/public/images/swish/1483985988355-763728e1935b.jpg +0 -0
- package/docs/public/images/swish/1485518882345-15568b007407.jpg +0 -0
- package/docs/public/images/swish/1490481651871-ab68de25d43d.jpg +0 -0
- package/docs/public/images/swish/1496747611176-843222e1e57c.jpg +0 -0
- package/docs/public/images/swish/1502716119720-b23a93e5fe1b.jpg +0 -0
- package/docs/public/images/swish/1539109136881-3be0616acf4b.jpg +0 -0
- package/docs/public/images/swish/1551803091-e20673f15770.jpg +0 -0
- package/docs/public/images/swish/1558769132-cb1aea458c5e.jpg +0 -0
- package/docs/public/images/swish/1572804013309-59a88b7e92f1.jpg +0 -0
- package/docs/public/img/heic1501a.jpg +0 -0
- package/docs/public/img/placeholder-landscape.svg +0 -18
- package/docs/public/img/placeholder-square.svg +0 -14
- package/docs/public/img/placeholder-tall.svg +0 -22
- package/docs/public/journey.css +0 -528
- package/docs/public/menu.js +0 -327
- package/docs/public/pulse-ui.css +0 -3028
- package/docs/public/pulse-ui.js +0 -257
- package/docs/public/retrowheels.css +0 -425
- package/docs/public/shop.css +0 -269
- package/docs/public/sparkys.css +0 -382
- package/docs/public/suzukibento.css +0 -517
- package/docs/public/swish.css +0 -394
- package/docs/public/theme.css +0 -52
- package/docs/public/themes/lumio.css +0 -50
- package/docs/public/themes/observatory.css +0 -104
- package/docs/server.js +0 -161
- package/docs/src/lib/component-page.js +0 -49
- package/docs/src/lib/highlight.js +0 -2
- package/docs/src/lib/layout.js +0 -190
- package/docs/src/lib/metrics-store.js +0 -6
- package/docs/src/lib/nav.js +0 -171
- package/docs/src/lib/stats.js +0 -81
- package/docs/src/pages/accessibility.js +0 -157
- package/docs/src/pages/actions.js +0 -194
- package/docs/src/pages/auth.js +0 -176
- package/docs/src/pages/caching.js +0 -95
- package/docs/src/pages/components/accordion.js +0 -48
- package/docs/src/pages/components/alert.js +0 -35
- package/docs/src/pages/components/app-badge.js +0 -41
- package/docs/src/pages/components/avatar.js +0 -35
- package/docs/src/pages/components/badge.js +0 -36
- package/docs/src/pages/components/breadcrumbs.js +0 -94
- package/docs/src/pages/components/button.js +0 -84
- package/docs/src/pages/components/card.js +0 -225
- package/docs/src/pages/components/charts.js +0 -283
- package/docs/src/pages/components/checkbox.js +0 -129
- package/docs/src/pages/components/cluster.js +0 -47
- package/docs/src/pages/components/container.js +0 -40
- package/docs/src/pages/components/cta.js +0 -54
- package/docs/src/pages/components/divider.js +0 -37
- package/docs/src/pages/components/empty.js +0 -36
- package/docs/src/pages/components/feature.js +0 -60
- package/docs/src/pages/components/fieldset.js +0 -65
- package/docs/src/pages/components/fileupload.js +0 -128
- package/docs/src/pages/components/footer.js +0 -58
- package/docs/src/pages/components/grid.js +0 -169
- package/docs/src/pages/components/heading.js +0 -107
- package/docs/src/pages/components/hero.js +0 -123
- package/docs/src/pages/components/icons.js +0 -410
- package/docs/src/pages/components/image.js +0 -71
- package/docs/src/pages/components/input.js +0 -51
- package/docs/src/pages/components/media.js +0 -51
- package/docs/src/pages/components/modal.js +0 -111
- package/docs/src/pages/components/nav.js +0 -136
- package/docs/src/pages/components/phone-frame.js +0 -74
- package/docs/src/pages/components/progress.js +0 -102
- package/docs/src/pages/components/prose.js +0 -111
- package/docs/src/pages/components/pullquote.js +0 -71
- package/docs/src/pages/components/radio.js +0 -194
- package/docs/src/pages/components/rating.js +0 -106
- package/docs/src/pages/components/search.js +0 -61
- package/docs/src/pages/components/section.js +0 -98
- package/docs/src/pages/components/segmented.js +0 -121
- package/docs/src/pages/components/select.js +0 -45
- package/docs/src/pages/components/slider.js +0 -115
- package/docs/src/pages/components/spinner.js +0 -73
- package/docs/src/pages/components/stack.js +0 -48
- package/docs/src/pages/components/stat.js +0 -55
- package/docs/src/pages/components/stepper.js +0 -66
- package/docs/src/pages/components/table.js +0 -45
- package/docs/src/pages/components/textarea.js +0 -31
- package/docs/src/pages/components/toggle.js +0 -84
- package/docs/src/pages/components/tooltip.js +0 -48
- package/docs/src/pages/components.js +0 -200
- package/docs/src/pages/config.js +0 -193
- package/docs/src/pages/constraints.js +0 -100
- package/docs/src/pages/deploy.js +0 -233
- package/docs/src/pages/error-pages.js +0 -99
- package/docs/src/pages/extending.js +0 -198
- package/docs/src/pages/faq.js +0 -99
- package/docs/src/pages/forms.js +0 -129
- package/docs/src/pages/getting-started.js +0 -147
- package/docs/src/pages/guard.js +0 -120
- package/docs/src/pages/home.js +0 -350
- package/docs/src/pages/how-it-works.js +0 -244
- package/docs/src/pages/hydration.js +0 -108
- package/docs/src/pages/images.js +0 -120
- package/docs/src/pages/markdown.js +0 -156
- package/docs/src/pages/meta.js +0 -193
- package/docs/src/pages/mutations.js +0 -107
- package/docs/src/pages/navigation.js +0 -85
- package/docs/src/pages/performance.js +0 -157
- package/docs/src/pages/persist.js +0 -89
- package/docs/src/pages/project-structure.js +0 -91
- package/docs/src/pages/prompt-examples.js +0 -186
- package/docs/src/pages/raw-responses.js +0 -122
- package/docs/src/pages/routing.js +0 -120
- package/docs/src/pages/server-api.js +0 -291
- package/docs/src/pages/server-data.js +0 -203
- package/docs/src/pages/slash-commands.js +0 -55
- package/docs/src/pages/spec.js +0 -207
- package/docs/src/pages/state.js +0 -102
- package/docs/src/pages/store.js +0 -203
- package/docs/src/pages/streaming.js +0 -107
- package/docs/src/pages/stripe.js +0 -194
- package/docs/src/pages/supabase.js +0 -324
- package/docs/src/pages/templates/andypixel.js +0 -260
- package/docs/src/pages/templates/blog-post.js +0 -226
- package/docs/src/pages/templates/bookworms.js +0 -227
- package/docs/src/pages/templates/coutumes.js +0 -237
- package/docs/src/pages/templates/journey.js +0 -186
- package/docs/src/pages/templates/mobile-app.js +0 -371
- package/docs/src/pages/templates/retrowheels.js +0 -355
- package/docs/src/pages/templates/shop.js +0 -268
- package/docs/src/pages/templates/sparkys.js +0 -264
- package/docs/src/pages/templates/suzukibento.js +0 -200
- package/docs/src/pages/templates/swish.js +0 -313
- package/docs/src/pages/templates.js +0 -534
- package/docs/src/pages/testing.js +0 -199
- package/docs/src/pages/validation.js +0 -138
- package/examples/.claude/commands/pulse-build.md +0 -7
- package/examples/.claude/commands/pulse-dev.md +0 -7
- package/examples/.claude/commands/pulse-start.md +0 -9
- package/examples/.claude/commands/pulse-stop.md +0 -5
- package/examples/.claude/commands/verify.md +0 -84
- package/examples/.claude/coverage-check.js +0 -196
- package/examples/.claude/pulse-checklist.md +0 -189
- package/examples/andypixel.css +0 -237
- package/examples/andypixel.js +0 -258
- package/examples/bookworms.css +0 -422
- package/examples/bookworms.js +0 -225
- package/examples/contact.js +0 -166
- package/examples/counter.js +0 -94
- package/examples/dev.server.js +0 -110
- package/examples/examples.test.js +0 -394
- package/examples/journey.css +0 -528
- package/examples/journey.js +0 -186
- package/examples/pricing.js +0 -262
- package/examples/products.js +0 -190
- package/examples/public/.pulse-ui-version +0 -1
- package/examples/public/pulse-ui.css +0 -3028
- package/examples/public/pulse-ui.js +0 -257
- package/examples/quiz.js +0 -208
- package/examples/retrowheels.css +0 -425
- package/examples/retrowheels.js +0 -353
- package/examples/shared.js +0 -84
- package/examples/suzukibento.css +0 -513
- package/examples/suzukibento.js +0 -200
- package/examples/swish.css +0 -394
- package/examples/swish.js +0 -311
- package/examples/todos.js +0 -162
- package/src/ui/fieldset.js +0 -37
- package/src/ui/phone-frame.js +0 -40
- package/src/ui/search.js +0 -78
- package/src/ui/segmented.js +0 -50
- package/src/ui/tooltip.js +0 -28
|
@@ -1,199 +0,0 @@
|
|
|
1
|
-
import { renderLayout, h1, lead, section, codeBlock, callout, table } from '../lib/layout.js'
|
|
2
|
-
import { prevNext } from '../lib/nav.js'
|
|
3
|
-
import { highlight } from '../lib/highlight.js'
|
|
4
|
-
|
|
5
|
-
const { prev, next } = prevNext('/testing')
|
|
6
|
-
|
|
7
|
-
export default {
|
|
8
|
-
route: '/testing',
|
|
9
|
-
meta: {
|
|
10
|
-
theme: 'light',
|
|
11
|
-
title: 'Testing — Pulse Docs',
|
|
12
|
-
description: 'Test Pulse view functions with renderSync and render — query the HTML output with CSS-like selectors, no DOM required.',
|
|
13
|
-
styles: ['/theme.css', '/docs.css'],
|
|
14
|
-
},
|
|
15
|
-
view: () => renderLayout({
|
|
16
|
-
currentHref: '/testing',
|
|
17
|
-
prev,
|
|
18
|
-
next,
|
|
19
|
-
content: `
|
|
20
|
-
${h1('Testing')}
|
|
21
|
-
${lead('Pulse ships a built-in testing helper at <code>@invisibleloop/pulse/testing</code>. Render a spec\'s view in tests and query the HTML output with CSS-like selectors — no DOM, no jsdom, no extra dependencies.')}
|
|
22
|
-
|
|
23
|
-
${section('quick-start', 'Quick start')}
|
|
24
|
-
${codeBlock(highlight(`import { renderSync, render } from '@invisibleloop/pulse/testing'
|
|
25
|
-
import assert from 'node:assert/strict'
|
|
26
|
-
import spec from './src/pages/counter.js'
|
|
27
|
-
|
|
28
|
-
// Synchronous — calls view directly, mock state and server
|
|
29
|
-
const result = renderSync(spec, { state: { count: 5 } })
|
|
30
|
-
assert(result.has('button'))
|
|
31
|
-
assert.equal(result.get('#count').text, '5')
|
|
32
|
-
|
|
33
|
-
// Async — runs real spec.server fetchers (or pass server to mock them)
|
|
34
|
-
const result = await render(productSpec, {
|
|
35
|
-
server: { product: { id: 1, name: 'Widget', price: 9.99 } }
|
|
36
|
-
})
|
|
37
|
-
assert.equal(result.get('h1').text, 'Widget')
|
|
38
|
-
assert.equal(result.count('li'), 3)`, 'js'))}
|
|
39
|
-
|
|
40
|
-
${section('render-sync', 'renderSync(spec, options?)')}
|
|
41
|
-
<p>Synchronous. Calls the view function directly — no server fetcher resolution. The fastest path for unit testing pure view functions.</p>
|
|
42
|
-
${table(
|
|
43
|
-
['Option', 'Type', 'Default', 'Description'],
|
|
44
|
-
[
|
|
45
|
-
['<code>state</code>', '<code>object</code>', '<code>{}</code>', 'State overrides merged with <code>spec.state</code>.'],
|
|
46
|
-
['<code>server</code>', '<code>object</code>', '<code>{}</code>', 'Server state passed directly to the view. Fetchers are never called.'],
|
|
47
|
-
]
|
|
48
|
-
)}
|
|
49
|
-
${codeBlock(highlight(`import { renderSync } from '@invisibleloop/pulse/testing'
|
|
50
|
-
|
|
51
|
-
const result = renderSync(formSpec, {
|
|
52
|
-
state: { name: 'Alice', email: 'alice@example.com' },
|
|
53
|
-
server: { plans: [{ id: 'pro', label: 'Pro' }] },
|
|
54
|
-
})`, 'js'))}
|
|
55
|
-
|
|
56
|
-
${section('render-async', 'render(spec, options?)')}
|
|
57
|
-
<p>Async. Two modes — mock or integration:</p>
|
|
58
|
-
<ul>
|
|
59
|
-
<li><strong>Mock mode</strong> — pass <code>server</code> to use that data directly. Fetchers are not called. Fast and deterministic.</li>
|
|
60
|
-
<li><strong>Integration mode</strong> — omit <code>server</code> and real <code>spec.server</code> fetchers run. Pass <code>ctx</code> to set params, cookies, headers, etc.</li>
|
|
61
|
-
</ul>
|
|
62
|
-
${table(
|
|
63
|
-
['Option', 'Type', 'Default', 'Description'],
|
|
64
|
-
[
|
|
65
|
-
['<code>state</code>', '<code>object</code>', '<code>{}</code>', 'State overrides merged with <code>spec.state</code>.'],
|
|
66
|
-
['<code>server</code>', '<code>object</code>', '<code>undefined</code>', 'Server state passed directly to the view. When set, fetchers are skipped entirely.'],
|
|
67
|
-
['<code>ctx</code>', '<code>object</code>', '<code>{}</code>', 'Request context passed to <code>spec.server</code> fetchers (integration mode only). Accepts <code>params</code>, <code>query</code>, <code>cookies</code>, <code>headers</code>, etc.'],
|
|
68
|
-
]
|
|
69
|
-
)}
|
|
70
|
-
${codeBlock(highlight(`import { render } from '@invisibleloop/pulse/testing'
|
|
71
|
-
|
|
72
|
-
// Mock — skip fetchers
|
|
73
|
-
const result = await render(productSpec, {
|
|
74
|
-
server: { product: { id: 42, name: 'Gadget' } }
|
|
75
|
-
})
|
|
76
|
-
|
|
77
|
-
// Integration — real fetchers, with ctx
|
|
78
|
-
const result = await render(productSpec, {
|
|
79
|
-
ctx: { params: { id: '42' }, cookies: { session: 'abc' } }
|
|
80
|
-
})`, 'js'))}
|
|
81
|
-
|
|
82
|
-
${section('render-result', 'RenderResult')}
|
|
83
|
-
<p>Both functions return the same <code>RenderResult</code> object.</p>
|
|
84
|
-
${table(
|
|
85
|
-
['Property / method', 'Returns', 'Description'],
|
|
86
|
-
[
|
|
87
|
-
['<code>.html</code>', '<code>string</code>', 'Raw HTML string from the view.'],
|
|
88
|
-
['<code>.state</code>', '<code>object</code>', 'Client state used for rendering.'],
|
|
89
|
-
['<code>.server</code>', '<code>object</code>', 'Server state used for rendering.'],
|
|
90
|
-
['<code>.text()</code>', '<code>string</code>', 'All text content — tags stripped, entities decoded, whitespace collapsed.'],
|
|
91
|
-
['<code>.has(selector)</code>', '<code>boolean</code>', 'True if any element matches selector.'],
|
|
92
|
-
['<code>.find(selector)</code>', '<code>Element | null</code>', 'First matching element, or null.'],
|
|
93
|
-
['<code>.get(selector)</code>', '<code>Element</code>', 'First matching element. Throws with a clear message if not found.'],
|
|
94
|
-
['<code>.findAll(selector)</code>', '<code>Element[]</code>', 'All matching elements.'],
|
|
95
|
-
['<code>.count(selector)</code>', '<code>number</code>', 'Number of matching elements.'],
|
|
96
|
-
['<code>.attr(selector, name)</code>', '<code>string | null</code>', 'Attribute value of the first matching element. Null if element or attribute absent.'],
|
|
97
|
-
]
|
|
98
|
-
)}
|
|
99
|
-
|
|
100
|
-
${section('element', 'Element')}
|
|
101
|
-
<p>Elements returned by <code>find()</code>, <code>get()</code>, and <code>findAll()</code> support the same query methods scoped to their own subtree.</p>
|
|
102
|
-
${table(
|
|
103
|
-
['Property / method', 'Returns', 'Description'],
|
|
104
|
-
[
|
|
105
|
-
['<code>.tag</code>', '<code>string</code>', 'Tag name (lowercase).'],
|
|
106
|
-
['<code>.text</code>', '<code>string</code>', 'All text content within the element, whitespace-collapsed.'],
|
|
107
|
-
['<code>.attrs</code>', '<code>object</code>', 'Parsed attribute map. Boolean attrs (e.g. <code>disabled</code>) have value <code>true</code>.'],
|
|
108
|
-
['<code>.attr(name)</code>', '<code>string | null</code>', 'Get one attribute. Returns <code>""</code> for boolean attrs, <code>null</code> if absent — mirrors <code>getAttribute()</code>.'],
|
|
109
|
-
['<code>.find(selector)</code>', '<code>Element | null</code>', 'First matching descendant.'],
|
|
110
|
-
['<code>.findAll(selector)</code>', '<code>Element[]</code>', 'All matching descendants.'],
|
|
111
|
-
['<code>.has(selector)</code>', '<code>boolean</code>', 'True if any descendant matches selector.'],
|
|
112
|
-
]
|
|
113
|
-
)}
|
|
114
|
-
|
|
115
|
-
${section('selectors', 'Supported selectors')}
|
|
116
|
-
<p>The selector engine supports the most common CSS patterns including descendant combinators (<code>div p</code>, <code>.parent li</code>). Descendant selectors match the rightmost token within the ancestor context — if no ancestors match, the result is empty (count 0).</p>
|
|
117
|
-
${table(
|
|
118
|
-
['Selector', 'Example', 'Matches'],
|
|
119
|
-
[
|
|
120
|
-
['Tag', '<code>button</code>', 'Any <code><button></code>'],
|
|
121
|
-
['Class', '<code>.ui-btn</code>', 'Elements with that class'],
|
|
122
|
-
['ID', '<code>#submit</code>', 'Element with that id'],
|
|
123
|
-
['Attribute present','<code>[disabled]</code>', 'Elements with a <code>disabled</code> attribute'],
|
|
124
|
-
['Attribute value', '<code>[type="submit"]</code>', 'Elements where <code>type</code> equals <code>submit</code>'],
|
|
125
|
-
['Compound', '<code>button.primary[disabled]</code>', 'All conditions on the same element'],
|
|
126
|
-
['Descendant', '<code>tbody tr</code>', '<code><tr></code> elements inside <code><tbody></code>'],
|
|
127
|
-
]
|
|
128
|
-
)}
|
|
129
|
-
${codeBlock(highlight(`result.has('button') // any <button>
|
|
130
|
-
result.has('.ui-btn--primary') // BEM modifier class
|
|
131
|
-
result.has('[data-action="submit"]') // data attribute
|
|
132
|
-
result.has('input[type="email"][required]') // compound
|
|
133
|
-
result.get('form').findAll('input') // inputs inside form`, 'js'))}
|
|
134
|
-
|
|
135
|
-
${section('patterns', 'Common patterns')}
|
|
136
|
-
${codeBlock(highlight(`// Assert an element exists and check its text
|
|
137
|
-
assert.equal(result.get('h1').text, 'Page Title')
|
|
138
|
-
|
|
139
|
-
// Assert an element does NOT exist
|
|
140
|
-
assert(!result.has('.error-message'))
|
|
141
|
-
|
|
142
|
-
// Check an attribute value
|
|
143
|
-
assert.equal(result.attr('input[name="email"]', 'type'), 'email')
|
|
144
|
-
|
|
145
|
-
// Boolean attributes — attr() returns '' (not 'disabled')
|
|
146
|
-
assert.equal(result.attr('[disabled]', 'disabled'), '')
|
|
147
|
-
assert(result.get('[disabled]').attr('disabled') === '')
|
|
148
|
-
|
|
149
|
-
// Count elements
|
|
150
|
-
assert.equal(result.count('li'), 3)
|
|
151
|
-
|
|
152
|
-
// Inspect all items
|
|
153
|
-
const items = result.findAll('li')
|
|
154
|
-
assert.equal(items[0].text, 'Alpha')
|
|
155
|
-
assert.equal(items[1].text, 'Beta')
|
|
156
|
-
|
|
157
|
-
// Scope a search to a subtree
|
|
158
|
-
const form = result.get('form')
|
|
159
|
-
assert(form.has('button[type="submit"]'))
|
|
160
|
-
assert.equal(form.count('input'), 2)
|
|
161
|
-
|
|
162
|
-
// Text content decodes entities
|
|
163
|
-
// <p><b>bold</b></p> → text === '<b>bold</b>'
|
|
164
|
-
assert.equal(result.get('p').text, '<b>bold</b>')`, 'js'))}
|
|
165
|
-
|
|
166
|
-
${section('test-file', 'Example test file')}
|
|
167
|
-
${codeBlock(highlight(`/**
|
|
168
|
-
* src/pages/counter.test.js
|
|
169
|
-
* run: node src/pages/counter.test.js
|
|
170
|
-
*/
|
|
171
|
-
import { test } from 'node:test'
|
|
172
|
-
import assert from 'node:assert/strict'
|
|
173
|
-
import { renderSync } from '@invisibleloop/pulse/testing'
|
|
174
|
-
import spec from './counter.js'
|
|
175
|
-
|
|
176
|
-
test('renders the current count', () => {
|
|
177
|
-
const result = renderSync(spec, { state: { count: 7 } })
|
|
178
|
-
assert.equal(result.get('#count').text, '7')
|
|
179
|
-
})
|
|
180
|
-
|
|
181
|
-
test('increment mutation returns count + 1', () => {
|
|
182
|
-
const next = spec.mutations.increment({ count: 0 })
|
|
183
|
-
assert.equal(next.count, 1)
|
|
184
|
-
})
|
|
185
|
-
|
|
186
|
-
test('decrement mutation returns count - 1', () => {
|
|
187
|
-
const next = spec.mutations.decrement({ count: 5 })
|
|
188
|
-
assert.equal(next.count, 4)
|
|
189
|
-
})
|
|
190
|
-
|
|
191
|
-
test('view renders increment and decrement buttons', () => {
|
|
192
|
-
const result = renderSync(spec)
|
|
193
|
-
assert(result.has('[data-event="increment"]'))
|
|
194
|
-
assert(result.has('[data-event="decrement"]'))
|
|
195
|
-
})`, 'js'))}
|
|
196
|
-
${callout('note', 'Use <code>renderSync</code> for mutations and pure view tests — it\'s synchronous and needs no <code>await</code>. Use <code>render</code> when your spec has <code>server</code> fetchers you want to exercise for integration coverage.')}
|
|
197
|
-
`,
|
|
198
|
-
}),
|
|
199
|
-
}
|
|
@@ -1,138 +0,0 @@
|
|
|
1
|
-
import { renderLayout, h1, lead, section, sub, codeBlock, callout, table } from '../lib/layout.js'
|
|
2
|
-
import { prevNext } from '../lib/nav.js'
|
|
3
|
-
import { highlight } from '../lib/highlight.js'
|
|
4
|
-
|
|
5
|
-
const { prev, next } = prevNext('/validation')
|
|
6
|
-
|
|
7
|
-
export default {
|
|
8
|
-
route: '/validation',
|
|
9
|
-
meta: {
|
|
10
|
-
theme: 'light',
|
|
11
|
-
title: 'Validation — Pulse Docs',
|
|
12
|
-
description: 'Declarative validation rules in Pulse — syntax, formats, dot-path notation, and error handling.',
|
|
13
|
-
styles: ['/theme.css', '/docs.css'],
|
|
14
|
-
},
|
|
15
|
-
view: () => renderLayout({
|
|
16
|
-
currentHref: '/validation',
|
|
17
|
-
prev,
|
|
18
|
-
next,
|
|
19
|
-
content: `
|
|
20
|
-
${h1('Validation')}
|
|
21
|
-
${lead('Validation rules are declared in the spec, co-located with the state they guard. When an action sets <code>validate: true</code>, Pulse enforces every rule before the async work runs. Invalid data cannot reach <code>run()</code>.')}
|
|
22
|
-
|
|
23
|
-
${section('declaring', 'Declaring validation rules')}
|
|
24
|
-
<p>The <code>validation</code> field maps dot-path state keys to rule objects:</p>
|
|
25
|
-
${codeBlock(highlight(`export default {
|
|
26
|
-
route: '/signup',
|
|
27
|
-
state: {
|
|
28
|
-
fields: { name: '', email: '', age: '', website: '' },
|
|
29
|
-
},
|
|
30
|
-
validation: {
|
|
31
|
-
'fields.name': { required: true, minLength: 2, maxLength: 100 },
|
|
32
|
-
'fields.email': { required: true, format: 'email' },
|
|
33
|
-
'fields.age': { required: true, min: 18, max: 120 },
|
|
34
|
-
'fields.website': { format: 'url' }, // optional field, but must be valid URL if provided
|
|
35
|
-
},
|
|
36
|
-
// ...
|
|
37
|
-
}`, 'js'))}
|
|
38
|
-
|
|
39
|
-
${section('rules', 'Available rules')}
|
|
40
|
-
${table(
|
|
41
|
-
['Rule', 'Type', 'Description'],
|
|
42
|
-
[
|
|
43
|
-
['<code>required</code>', '<code>boolean</code>', 'Field must be present and non-empty.'],
|
|
44
|
-
['<code>minLength</code>', '<code>number</code>', 'String must be at least N characters.'],
|
|
45
|
-
['<code>maxLength</code>', '<code>number</code>', 'String must be at most N characters.'],
|
|
46
|
-
['<code>min</code>', '<code>number</code>', 'Numeric value must be ≥ N.'],
|
|
47
|
-
['<code>max</code>', '<code>number</code>', 'Numeric value must be ≤ N.'],
|
|
48
|
-
['<code>pattern</code>', '<code>RegExp | string</code>', 'Value must match the regular expression.'],
|
|
49
|
-
['<code>format</code>', '<code>string</code>', 'Named format: <code>email</code>, <code>url</code>, or <code>numeric</code>.'],
|
|
50
|
-
]
|
|
51
|
-
)}
|
|
52
|
-
|
|
53
|
-
${section('formats', 'Named formats')}
|
|
54
|
-
${table(
|
|
55
|
-
['Format', 'What it checks'],
|
|
56
|
-
[
|
|
57
|
-
['<code>email</code>', 'Basic email structure — must contain <code>@</code> and a domain.'],
|
|
58
|
-
['<code>url</code>', 'Must start with <code>http://</code> or <code>https://</code>.'],
|
|
59
|
-
['<code>numeric</code>', 'Must consist entirely of digit characters.'],
|
|
60
|
-
]
|
|
61
|
-
)}
|
|
62
|
-
|
|
63
|
-
${section('dot-paths', 'Dot-path notation')}
|
|
64
|
-
<p>Validation keys are dot-paths into the current <code>state</code>, allowing nested fields to be validated without any special syntax:</p>
|
|
65
|
-
${codeBlock(highlight(`state: {
|
|
66
|
-
billing: {
|
|
67
|
-
address: { street: '', city: '', postcode: '' },
|
|
68
|
-
card: { number: '', expiry: '' },
|
|
69
|
-
},
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
validation: {
|
|
73
|
-
'billing.address.street': { required: true },
|
|
74
|
-
'billing.address.city': { required: true },
|
|
75
|
-
'billing.address.postcode': { required: true, pattern: /^[A-Z]{1,2}\\d[A-Z\\d]? \\d[A-Z]{2}$/i },
|
|
76
|
-
'billing.card.number': { required: true, format: 'numeric', minLength: 16, maxLength: 16 },
|
|
77
|
-
'billing.card.expiry': { required: true },
|
|
78
|
-
}`, 'js'))}
|
|
79
|
-
|
|
80
|
-
${section('when-runs', 'When validation runs')}
|
|
81
|
-
<p>Validation only runs when an action declares <code>validate: true</code>. The order is enforced by the framework:</p>
|
|
82
|
-
<ol>
|
|
83
|
-
<li><code>onStart</code> captures <code>FormData</code> values into state</li>
|
|
84
|
-
<li>Validation reads those values from state using dot-paths</li>
|
|
85
|
-
<li>If any rules fail, <code>onError</code> is called immediately — <code>run</code> is skipped</li>
|
|
86
|
-
</ol>
|
|
87
|
-
${callout('note', 'Validation reads from <strong>state</strong>, not from raw <code>FormData</code>. <code>onStart</code> must copy form values into state first — this is what makes them available to dot-path rules.')}
|
|
88
|
-
|
|
89
|
-
${section('error-structure', 'Error structure')}
|
|
90
|
-
<p>When validation fails, the runtime throws an error object with a <code>validation</code> array:</p>
|
|
91
|
-
${codeBlock(highlight(`{
|
|
92
|
-
message: 'Validation failed',
|
|
93
|
-
validation: [
|
|
94
|
-
{ field: 'fields.email', rule: 'format', message: 'Must be a valid email address' },
|
|
95
|
-
{ field: 'fields.name', rule: 'required', message: 'Required' },
|
|
96
|
-
{ field: 'fields.age', rule: 'min', message: 'Must be at least 18' },
|
|
97
|
-
]
|
|
98
|
-
}`, 'js'))}
|
|
99
|
-
<p>In your action's <code>onError</code>, check for <code>err?.validation</code> to distinguish validation errors from other failures:</p>
|
|
100
|
-
${codeBlock(highlight(`onError: (state, err) => ({
|
|
101
|
-
status: 'error',
|
|
102
|
-
errors: err?.validation ?? [{ message: err.message }],
|
|
103
|
-
})`, 'js'))}
|
|
104
|
-
|
|
105
|
-
${section('rendering', 'Rendering errors')}
|
|
106
|
-
<p>The errors array maps to UI in the view — a global error list, or inline errors using the <code>field</code> property to place them next to each input:</p>
|
|
107
|
-
${codeBlock(highlight(`view: (state) => {
|
|
108
|
-
const errFor = (field) => {
|
|
109
|
-
const e = state.errors.find(e => e.field === field)
|
|
110
|
-
return e ? \`<p class="field-error">\${e.message}</p>\` : ''
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
return \`
|
|
114
|
-
<form data-action="submit">
|
|
115
|
-
<label>
|
|
116
|
-
Email
|
|
117
|
-
<input name="email" type="email" value="\${state.fields.email}">
|
|
118
|
-
\${errFor('fields.email')}
|
|
119
|
-
</label>
|
|
120
|
-
<label>
|
|
121
|
-
Name
|
|
122
|
-
<input name="name" type="text" value="\${state.fields.name}">
|
|
123
|
-
\${errFor('fields.name')}
|
|
124
|
-
</label>
|
|
125
|
-
<button>Submit</button>
|
|
126
|
-
</form>
|
|
127
|
-
\`
|
|
128
|
-
}`, 'js'))}
|
|
129
|
-
|
|
130
|
-
${section('optional-fields', 'Optional fields')}
|
|
131
|
-
<p>Omit <code>required: true</code> for optional fields. Other rules (format, minLength, etc.) are only enforced when the field has a value — empty optional fields always pass:</p>
|
|
132
|
-
${codeBlock(highlight(`validation: {
|
|
133
|
-
// website is optional, but must be a valid URL if provided
|
|
134
|
-
'fields.website': { format: 'url' },
|
|
135
|
-
}`, 'js'))}
|
|
136
|
-
`,
|
|
137
|
-
}),
|
|
138
|
-
}
|
|
@@ -1,84 +0,0 @@
|
|
|
1
|
-
# Verify
|
|
2
|
-
|
|
3
|
-
Run the full verification loop on the current page or the page specified in $ARGUMENTS.
|
|
4
|
-
|
|
5
|
-
## Steps
|
|
6
|
-
|
|
7
|
-
### 1. Identify the target
|
|
8
|
-
|
|
9
|
-
If $ARGUMENTS is provided, use it as the file path or route. Otherwise, identify the most recently edited spec file from context.
|
|
10
|
-
|
|
11
|
-
### 2. Validate the spec
|
|
12
|
-
|
|
13
|
-
Use `pulse_validate` with the spec file content. If validation fails, report every error clearly and stop — do not proceed to screenshot until the spec is valid.
|
|
14
|
-
|
|
15
|
-
### 3. Check the dev server
|
|
16
|
-
|
|
17
|
-
Use `pulse_fetch_page` to confirm the server is responding for the route. If it errors, use `pulse_restart_server` and retry once.
|
|
18
|
-
|
|
19
|
-
**If `pulse_fetch_page` returns unexpected HTML (e.g. a different project's page title, a Next.js 404, or any non-Pulse response):** check the `X-Pulse` response header — Pulse servers always send this. If it is absent, the wrong server is running on that port. Stop and inform the user before continuing.
|
|
20
|
-
|
|
21
|
-
### 4. Screenshot
|
|
22
|
-
|
|
23
|
-
Use `mcp__chrome-devtools__navigate_page` to load the page route, then `mcp__chrome-devtools__take_screenshot` to capture the result. Describe what you see — layout, content, any obvious rendering issues.
|
|
24
|
-
|
|
25
|
-
### 5. Lighthouse — desktop
|
|
26
|
-
|
|
27
|
-
Run `mcp__chrome-devtools__lighthouse_audit` on the route with `{ "strategy": "desktop" }`.
|
|
28
|
-
|
|
29
|
-
**Pass bar: Accessibility, Best Practices, and SEO must all be 100.** Performance is measured and reported but is not a hard requirement (it varies with machine load). Report the actual scores. If Accessibility, Best Practices, or SEO is below 100, identify the failing audit(s), fix the issue, and restart from step 2.
|
|
30
|
-
|
|
31
|
-
### 6. Lighthouse — mobile
|
|
32
|
-
|
|
33
|
-
Run `mcp__chrome-devtools__lighthouse_audit` on the same route with `{ "strategy": "mobile" }`.
|
|
34
|
-
|
|
35
|
-
**Same pass bar: Accessibility, Best Practices, and SEO must all be 100.** Fix any failures and restart from step 2.
|
|
36
|
-
|
|
37
|
-
**If you need to visually inspect the layout at a mobile viewport width** (e.g. to debug a wrapping or overflow issue), use `mcp__chrome-devtools__emulate` with `viewport: "390x844x2,mobile,touch"` — not `resize_page`. The `emulate` tool reliably sets the viewport; `resize_page` does not reliably trigger responsive layout changes. Reset afterward with `emulate` using the desktop viewport: `"1440x900x1"`.
|
|
38
|
-
|
|
39
|
-
### 7. Performance
|
|
40
|
-
|
|
41
|
-
Navigate to the page route, then run `mcp__chrome-devtools__performance_start_trace` with `reload: true` and `autoStop: true`. Report LCP and CLS. Flag any LCP insight that suggests a fixable problem (render-blocking resources, large image load delay, etc.).
|
|
42
|
-
|
|
43
|
-
### 8. Console errors
|
|
44
|
-
|
|
45
|
-
Use `mcp__chrome-devtools__list_console_messages` — report any errors or unexpected warnings.
|
|
46
|
-
|
|
47
|
-
### 9. Code review
|
|
48
|
-
|
|
49
|
-
Call `pulse_review` with the absolute path to the spec file. Read the source, rendered HTML, validator output, and checklist it returns. Fix every issue before proceeding. This is the structured review — do not skip it or replace it with a manual checklist read.
|
|
50
|
-
|
|
51
|
-
### 10. Close the browser
|
|
52
|
-
|
|
53
|
-
Use `mcp__chrome-devtools__list_pages` to get all open pages, then call `mcp__chrome-devtools__close_page` for every page ID returned. This closes the entire browser, not just the tab. `pageId` must be a number, not a string.
|
|
54
|
-
|
|
55
|
-
### 11. Write verification stamp
|
|
56
|
-
|
|
57
|
-
Run:
|
|
58
|
-
|
|
59
|
-
```bash
|
|
60
|
-
date +%s > .pulse-verified
|
|
61
|
-
```
|
|
62
|
-
|
|
63
|
-
**This must be the last write operation before stopping.** The stop hook compares each edited spec's modification time against this stamp — if any spec is newer than the stamp, the hook blocks. Do not edit any spec file after writing the stamp.
|
|
64
|
-
|
|
65
|
-
### 12. Report
|
|
66
|
-
|
|
67
|
-
Summarise:
|
|
68
|
-
- Validation: pass or fail (with errors if any)
|
|
69
|
-
- Visual: what was visible in the screenshot
|
|
70
|
-
- Lighthouse desktop: scores for all four categories
|
|
71
|
-
- Lighthouse mobile: scores for all four categories
|
|
72
|
-
- Performance: LCP and CLS values, any flagged insights
|
|
73
|
-
- Console: any errors
|
|
74
|
-
- Review: pass or issues found and fixed
|
|
75
|
-
|
|
76
|
-
**Pass bar — all of these must be met before the page is confirmed good:**
|
|
77
|
-
- Validation: clean (no errors, no warnings)
|
|
78
|
-
- Lighthouse desktop: Accessibility, Best Practices, SEO all 100
|
|
79
|
-
- Lighthouse mobile: same
|
|
80
|
-
- CLS: 0.00 — any layout shift is a blocker
|
|
81
|
-
- LCP: report the value; flag and fix any insight that identifies a fixable cause
|
|
82
|
-
- Console: no errors
|
|
83
|
-
|
|
84
|
-
If any gate fails, fix the issue and run `/verify` again.
|
|
@@ -1,196 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
/**
|
|
3
|
-
* coverage-check.js
|
|
4
|
-
*
|
|
5
|
-
* Called by the Stop hook in .claude/settings.json to enforce test coverage.
|
|
6
|
-
* Runs `npm run test:coverage` and blocks the agent if any src/pages/ file
|
|
7
|
-
* has uncovered lines — except code that cannot be unit-tested without mocking
|
|
8
|
-
* real APIs:
|
|
9
|
-
*
|
|
10
|
-
* - server fetchers (`server:` blocks) and store fetchers
|
|
11
|
-
* - action `run:` functions
|
|
12
|
-
* - `guard:`, `submit:`, and `sitemap:` handlers
|
|
13
|
-
* - `meta:` blocks (async meta functions hit real data sources)
|
|
14
|
-
*
|
|
15
|
-
* Also honours c8-style ignore comments, which Node's built-in coverage
|
|
16
|
-
* (--experimental-test-coverage) does NOT support natively. In a block or line
|
|
17
|
-
* comment, write:
|
|
18
|
-
*
|
|
19
|
-
* "c8 ignore next" — exempts the following line
|
|
20
|
-
* "c8 ignore next 3" — exempts the following 3 lines
|
|
21
|
-
* "c8 ignore start" ... "c8 ignore stop" — exempts the range
|
|
22
|
-
* ("coverage-ignore" works as a synonym for "c8 ignore")
|
|
23
|
-
*
|
|
24
|
-
* Outputs a Claude Code hook JSON decision to stdout.
|
|
25
|
-
*/
|
|
26
|
-
|
|
27
|
-
import { execSync } from 'child_process'
|
|
28
|
-
import fs from 'fs'
|
|
29
|
-
import { pathToFileURL } from 'url'
|
|
30
|
-
|
|
31
|
-
// Property blocks that are exempt — all server-side-only code paths that call
|
|
32
|
-
// real external APIs and cannot run in unit tests.
|
|
33
|
-
const EXEMPT_BLOCK_STARTS = [
|
|
34
|
-
/^server\s*:/,
|
|
35
|
-
/^run\s*:/, /^run\s*\(/,
|
|
36
|
-
/^guard\s*:/,
|
|
37
|
-
/^submit\s*:/,
|
|
38
|
-
/^sitemap\s*:/,
|
|
39
|
-
/^meta\s*:/,
|
|
40
|
-
]
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* Build the set of exempt (1-based) line numbers for a source string:
|
|
44
|
-
* lines inside exempt property blocks, plus c8-ignore-annotated lines.
|
|
45
|
-
*
|
|
46
|
-
* @param {string} src
|
|
47
|
-
* @returns {Set<number>}
|
|
48
|
-
*/
|
|
49
|
-
export function computeExemptLines(src) {
|
|
50
|
-
const srcLines = src.split('\n')
|
|
51
|
-
const exemptLines = new Set()
|
|
52
|
-
|
|
53
|
-
// 1. Exempt property blocks — scan tracking brace/paren depth from block start
|
|
54
|
-
let i = 0
|
|
55
|
-
while (i < srcLines.length) {
|
|
56
|
-
const trimmed = srcLines[i].trimStart()
|
|
57
|
-
if (EXEMPT_BLOCK_STARTS.some(re => re.test(trimmed))) {
|
|
58
|
-
let depth = 0
|
|
59
|
-
let started = false
|
|
60
|
-
while (i < srcLines.length) {
|
|
61
|
-
exemptLines.add(i + 1) // 1-based
|
|
62
|
-
for (const ch of srcLines[i]) {
|
|
63
|
-
if (ch === '{' || ch === '(') { depth++; started = true }
|
|
64
|
-
if (ch === '}' || ch === ')') { depth-- }
|
|
65
|
-
}
|
|
66
|
-
if (started && depth <= 0) break
|
|
67
|
-
i++
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
i++
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
// 2. c8-style ignore comments (Node's built-in coverage ignores these, so we
|
|
74
|
-
// honour them here — the marker line itself is always exempt too)
|
|
75
|
-
const IGNORE = /(?:c8 ignore|coverage-ignore)(?:\s+(next|start|stop))?(?:\s+(\d+))?/
|
|
76
|
-
let rangeOpen = false
|
|
77
|
-
for (let n = 0; n < srcLines.length; n++) {
|
|
78
|
-
if (rangeOpen) exemptLines.add(n + 1)
|
|
79
|
-
const m = srcLines[n].match(IGNORE)
|
|
80
|
-
if (!m) continue
|
|
81
|
-
exemptLines.add(n + 1)
|
|
82
|
-
const keyword = m[1]
|
|
83
|
-
if (keyword === 'start') { rangeOpen = true; continue }
|
|
84
|
-
if (keyword === 'stop') { rangeOpen = false; continue }
|
|
85
|
-
// 'next' (or bare marker) — exempt the following N lines (default 1)
|
|
86
|
-
const count = m[2] ? parseInt(m[2], 10) : 1
|
|
87
|
-
for (let k = 1; k <= count; k++) exemptLines.add(n + 1 + k)
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
return exemptLines
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
/**
|
|
94
|
-
* Returns true if all uncovered line numbers fall on exempt lines.
|
|
95
|
-
*
|
|
96
|
-
* @param {string} filePath
|
|
97
|
-
* @param {string} uncoveredSpec - e.g. "30-36 50"
|
|
98
|
-
* @returns {boolean}
|
|
99
|
-
*/
|
|
100
|
-
export function allLinesExempt(filePath, uncoveredSpec) {
|
|
101
|
-
let src
|
|
102
|
-
try { src = fs.readFileSync(filePath, 'utf8') } catch { return false }
|
|
103
|
-
|
|
104
|
-
// Expand "30-36 50" → [30, 31, 32, 33, 34, 35, 36, 50]
|
|
105
|
-
const lineNums = []
|
|
106
|
-
for (const part of uncoveredSpec.split(/\s+/)) {
|
|
107
|
-
const range = part.match(/^(\d+)-(\d+)$/)
|
|
108
|
-
if (range) {
|
|
109
|
-
for (let i = parseInt(range[1]); i <= parseInt(range[2]); i++) lineNums.push(i)
|
|
110
|
-
} else if (/^\d+$/.test(part)) {
|
|
111
|
-
lineNums.push(parseInt(part))
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
const exemptLines = computeExemptLines(src)
|
|
116
|
-
return lineNums.every(n => exemptLines.has(n))
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
// ---------------------------------------------------------------------------
|
|
120
|
-
// Main — only when executed directly (the exports above are unit-tested)
|
|
121
|
-
// ---------------------------------------------------------------------------
|
|
122
|
-
|
|
123
|
-
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
124
|
-
main()
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
function main() {
|
|
128
|
-
// Agent is ending its turn to wait for the user's answer (approval gate or a
|
|
129
|
-
// mid-flow pause) — let the turn end. The marker is consumed on the user's
|
|
130
|
-
// next prompt, so the gates return in full force afterwards.
|
|
131
|
-
if (fs.existsSync('.pulse-awaiting-approval')) process.exit(0)
|
|
132
|
-
|
|
133
|
-
// No test files → nothing to check
|
|
134
|
-
const hasTests = fs.existsSync('src/pages') && (function scan(d) {
|
|
135
|
-
for (const f of fs.readdirSync(d)) {
|
|
136
|
-
const p = `${d}/${f}`
|
|
137
|
-
if (fs.statSync(p).isDirectory()) { if (scan(p)) return true }
|
|
138
|
-
else if (f.endsWith('.test.js')) return true
|
|
139
|
-
}
|
|
140
|
-
return false
|
|
141
|
-
})('src/pages')
|
|
142
|
-
|
|
143
|
-
if (!hasTests) process.exit(0)
|
|
144
|
-
|
|
145
|
-
let out = ''
|
|
146
|
-
try {
|
|
147
|
-
out = execSync('npm run test:coverage 2>&1', { encoding: 'utf8', timeout: 60000 })
|
|
148
|
-
} catch (e) {
|
|
149
|
-
out = e.stdout || e.message
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
// Parse the coverage report — look for lines with uncovered line numbers
|
|
153
|
-
const lines = out.split('\n')
|
|
154
|
-
let inReport = false
|
|
155
|
-
const rawGaps = []
|
|
156
|
-
|
|
157
|
-
for (const line of lines) {
|
|
158
|
-
if (line.includes('start of coverage report')) { inReport = true; continue }
|
|
159
|
-
if (!inReport) continue
|
|
160
|
-
|
|
161
|
-
// Match: # filename.js | line% | branch% | func% | 30-36 50
|
|
162
|
-
const m = line.match(/^[#ℹ]\s+([\w./[\]-]+\.js)\s*\|\s*[\d.]+\s*\|\s*[\d.]+\s*\|\s*[\d.]+\s*\|\s*([^|\n]+)$/)
|
|
163
|
-
if (m) {
|
|
164
|
-
const uncov = m[2].trim()
|
|
165
|
-
if (uncov && uncov !== 'uncovered lines') {
|
|
166
|
-
rawGaps.push({ file: m[1].trim(), uncovered: uncov })
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
const gaps = []
|
|
172
|
-
for (const { file, uncovered } of rawGaps) {
|
|
173
|
-
// Try to resolve relative to cwd (coverage reports use relative paths)
|
|
174
|
-
const candidates = [file, `src/pages/${file}`, file.replace(/^.*src\/pages\//, 'src/pages/')]
|
|
175
|
-
const resolved = candidates.find(p => fs.existsSync(p))
|
|
176
|
-
|
|
177
|
-
if (resolved && allLinesExempt(resolved, uncovered)) continue // auto-exempt
|
|
178
|
-
|
|
179
|
-
gaps.push(` ${file} — uncovered lines: ${uncovered}`)
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
if (gaps.length) {
|
|
183
|
-
process.stdout.write(JSON.stringify({
|
|
184
|
-
decision: 'block',
|
|
185
|
-
reason: [
|
|
186
|
-
'COVERAGE GAPS detected. Add tests for these uncovered lines before finishing:',
|
|
187
|
-
...gaps,
|
|
188
|
-
'',
|
|
189
|
-
'Run `npm run test:coverage` to see the full report.',
|
|
190
|
-
'Auto-exempt: server fetchers, action run(), guard, submit, sitemap, and meta blocks — these call real APIs and need no tests.',
|
|
191
|
-
'For anything else that genuinely cannot be tested, annotate it: /* c8 ignore next */ (or next N, or start/stop for a range) — this hook honours those comments even though Node\'s built-in coverage does not.',
|
|
192
|
-
'Everything else — view branches, mutations, onViewError, pure helper functions — must be covered.',
|
|
193
|
-
].join('\n'),
|
|
194
|
-
}))
|
|
195
|
-
}
|
|
196
|
-
}
|