@invisibleloop/pulse 0.20.10 → 0.20.19
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 +14 -7
- package/src/runtime/ssr.test.js +31 -0
- package/src/server/index.js +163 -10
- 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 -345
- 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
package/src/cli/stamp.js
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pulse — verification stamp (shared logic)
|
|
3
|
+
*
|
|
4
|
+
* Writes .pulse-verified to clear the stop-hook verification gate. Used by
|
|
5
|
+
* both pulse_stamp (MCP tool, for an agent) and `pulse stamp` (CLI command,
|
|
6
|
+
* for a human). Neither wraps the other — both call writeStamp().
|
|
7
|
+
*
|
|
8
|
+
* In full mode (route + mode: 'full'), refuses to stamp unless a Lighthouse
|
|
9
|
+
* report was actually saved via `pulse save-report` in the last 10 minutes
|
|
10
|
+
* — a real Lighthouse run that passed the score bar but was never saved
|
|
11
|
+
* leaves nothing for `pulse report-server` or a dashboard to show. This is
|
|
12
|
+
* a mechanized version of a check that used to be prose-only in verify.md
|
|
13
|
+
* and was found (via a real user report) to be silently skipped: /verify
|
|
14
|
+
* completed and reported scores in the conversation, but nothing ever
|
|
15
|
+
* landed in .pulse/reports/.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import fs from 'fs'
|
|
19
|
+
import path from 'path'
|
|
20
|
+
import { urlToSlug } from './report.js'
|
|
21
|
+
import { resolveEntries } from './diagnose.js'
|
|
22
|
+
|
|
23
|
+
const STAMP_REPORT_WINDOW_MS = 10 * 60 * 1000 // a report older than this wasn't from this /verify run
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @param {string} root
|
|
27
|
+
* @param {{ route?: string, mode?: 'full'|'quick' }} [opts]
|
|
28
|
+
* @returns {{ stamped: boolean, refused: boolean, reason: string|null, resolvedCount: number, ts: string|null }}
|
|
29
|
+
* stamped: whether .pulse-verified was actually written
|
|
30
|
+
* refused: true if this was a policy refusal (missing report) — false if a real write error
|
|
31
|
+
* reason: why it didn't stamp, when stamped is false (null otherwise)
|
|
32
|
+
* resolvedCount: error-journal entries auto-resolved for `route` (0 if no route passed)
|
|
33
|
+
* ts: the stamp's unix-seconds timestamp, when stamped is true (null otherwise)
|
|
34
|
+
*/
|
|
35
|
+
export function writeStamp(root, { route, mode } = {}) {
|
|
36
|
+
if (route && mode === 'full') {
|
|
37
|
+
const slug = urlToSlug(route)
|
|
38
|
+
const reportDir = path.join(root, '.pulse', 'reports', slug)
|
|
39
|
+
let recentReport = false
|
|
40
|
+
try {
|
|
41
|
+
const files = fs.readdirSync(reportDir).filter(f => f.endsWith('.json'))
|
|
42
|
+
recentReport = files.some(f => {
|
|
43
|
+
const stat = fs.statSync(path.join(reportDir, f))
|
|
44
|
+
return Date.now() - stat.mtimeMs < STAMP_REPORT_WINDOW_MS
|
|
45
|
+
})
|
|
46
|
+
} catch { /* reportDir doesn't exist — recentReport stays false */ }
|
|
47
|
+
|
|
48
|
+
if (!recentReport) {
|
|
49
|
+
return {
|
|
50
|
+
stamped: false,
|
|
51
|
+
refused: true,
|
|
52
|
+
reason: `No Lighthouse report saved for "${route}" in the last 10 minutes. Full /verify requires ` +
|
|
53
|
+
`pulse save-report --url "http://localhost:3001${route}" --data '{"scores":{...},"metrics":{...}}' ` +
|
|
54
|
+
`(run from the project root) for BOTH the desktop and mobile Lighthouse runs before stamping.`,
|
|
55
|
+
resolvedCount: 0,
|
|
56
|
+
ts: null,
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const stampPath = path.join(root, '.pulse-verified')
|
|
62
|
+
const ts = String(Math.floor(Date.now() / 1000))
|
|
63
|
+
try {
|
|
64
|
+
fs.writeFileSync(stampPath, ts, 'utf8')
|
|
65
|
+
} catch (err) {
|
|
66
|
+
return {
|
|
67
|
+
stamped: false,
|
|
68
|
+
refused: false,
|
|
69
|
+
reason: `Error writing .pulse-verified: ${err.message}. Fallback: run \`date +%s > .pulse-verified\` in Bash.`,
|
|
70
|
+
resolvedCount: 0,
|
|
71
|
+
ts: null,
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
let resolvedCount = 0
|
|
76
|
+
if (route) {
|
|
77
|
+
const { count } = resolveEntries(root, { route })
|
|
78
|
+
resolvedCount = count
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return { stamped: true, refused: false, reason: null, resolvedCount, ts }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Format a writeStamp() result as human-readable text.
|
|
86
|
+
* @param {ReturnType<typeof writeStamp>} result
|
|
87
|
+
* @param {{ agentFacing?: boolean }} [opts]
|
|
88
|
+
*/
|
|
89
|
+
export function formatStampResult(result, { agentFacing = true } = {}) {
|
|
90
|
+
if (!result.stamped) {
|
|
91
|
+
const prefix = result.refused ? 'Refusing to stamp' : 'Failed to stamp'
|
|
92
|
+
return agentFacing && result.refused
|
|
93
|
+
? `${prefix}: ${result.reason}\n\nRun it now with the real numbers from your Lighthouse results, then call pulse_stamp again.`
|
|
94
|
+
: `${prefix}: ${result.reason}`
|
|
95
|
+
}
|
|
96
|
+
const resolvedNote = result.resolvedCount > 0
|
|
97
|
+
? ` ${result.resolvedCount} error-journal entr${result.resolvedCount === 1 ? 'y' : 'ies'} marked resolved.`
|
|
98
|
+
: ''
|
|
99
|
+
return agentFacing
|
|
100
|
+
? `✓ .pulse-verified written (${result.ts}). Stop hook cleared — session can end.${resolvedNote}`
|
|
101
|
+
: `✓ .pulse-verified written (${result.ts}).${resolvedNote}`
|
|
102
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pulse — stamp.js tests
|
|
3
|
+
* run: node src/cli/stamp.test.js
|
|
4
|
+
*
|
|
5
|
+
* Shared logic behind pulse_stamp (MCP tool) and `pulse stamp` (CLI
|
|
6
|
+
* command). Regression coverage: a real Lighthouse run that passed the
|
|
7
|
+
* score bar in a /verify session was found to be silently never saved via
|
|
8
|
+
* `pulse save-report` — prose-only documentation for that step wasn't
|
|
9
|
+
* enough, so full-mode pulse_stamp now refuses to write the stamp unless
|
|
10
|
+
* it finds real evidence a report was saved.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { test } from 'node:test'
|
|
14
|
+
import assert from 'node:assert/strict'
|
|
15
|
+
import fs from 'node:fs'
|
|
16
|
+
import os from 'node:os'
|
|
17
|
+
import path from 'node:path'
|
|
18
|
+
import { writeStamp, formatStampResult } from './stamp.js'
|
|
19
|
+
|
|
20
|
+
function tmpProject() {
|
|
21
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), 'pulse-stamp-test-'))
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function writeReport(root, slug, { ageMs = 0 } = {}) {
|
|
25
|
+
const dir = path.join(root, '.pulse', 'reports', slug)
|
|
26
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
27
|
+
const file = path.join(dir, `${Date.now()}.json`)
|
|
28
|
+
fs.writeFileSync(file, JSON.stringify({ timestamp: new Date().toISOString(), scores: {} }))
|
|
29
|
+
if (ageMs > 0) {
|
|
30
|
+
const past = new Date(Date.now() - ageMs)
|
|
31
|
+
fs.utimesSync(file, past, past)
|
|
32
|
+
}
|
|
33
|
+
return file
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ── Quick mode / no route ───────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
test('writeStamp with no route stamps unconditionally', () => {
|
|
39
|
+
const root = tmpProject()
|
|
40
|
+
const result = writeStamp(root)
|
|
41
|
+
assert.equal(result.stamped, true)
|
|
42
|
+
assert.equal(fs.existsSync(path.join(root, '.pulse-verified')), true)
|
|
43
|
+
fs.rmSync(root, { recursive: true, force: true })
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
test('writeStamp in quick mode stamps even with no saved report', () => {
|
|
47
|
+
const root = tmpProject()
|
|
48
|
+
const result = writeStamp(root, { route: '/dashboard', mode: 'quick' })
|
|
49
|
+
assert.equal(result.stamped, true)
|
|
50
|
+
fs.rmSync(root, { recursive: true, force: true })
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
// ── Full mode — the actual regression this fixes ────────────────────────
|
|
54
|
+
|
|
55
|
+
test('writeStamp in full mode refuses when no report was ever saved for the route', () => {
|
|
56
|
+
const root = tmpProject()
|
|
57
|
+
const result = writeStamp(root, { route: '/dashboard', mode: 'full' })
|
|
58
|
+
assert.equal(result.stamped, false)
|
|
59
|
+
assert.equal(result.refused, true)
|
|
60
|
+
assert.match(result.reason, /No Lighthouse report saved/)
|
|
61
|
+
assert.equal(fs.existsSync(path.join(root, '.pulse-verified')), false, 'must not write the stamp file at all')
|
|
62
|
+
fs.rmSync(root, { recursive: true, force: true })
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
test('writeStamp in full mode refuses when the only report is older than the window', () => {
|
|
66
|
+
const root = tmpProject()
|
|
67
|
+
writeReport(root, 'dashboard', { ageMs: 20 * 60 * 1000 }) // 20 min old
|
|
68
|
+
const result = writeStamp(root, { route: '/dashboard', mode: 'full' })
|
|
69
|
+
assert.equal(result.stamped, false)
|
|
70
|
+
assert.equal(result.refused, true)
|
|
71
|
+
fs.rmSync(root, { recursive: true, force: true })
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
test('writeStamp in full mode stamps when a recent report exists for the route', () => {
|
|
75
|
+
const root = tmpProject()
|
|
76
|
+
writeReport(root, 'dashboard')
|
|
77
|
+
const result = writeStamp(root, { route: '/dashboard', mode: 'full' })
|
|
78
|
+
assert.equal(result.stamped, true)
|
|
79
|
+
assert.equal(result.refused, false)
|
|
80
|
+
assert.equal(fs.existsSync(path.join(root, '.pulse-verified')), true)
|
|
81
|
+
fs.rmSync(root, { recursive: true, force: true })
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
test('writeStamp full mode checks the same slug pulse save-report would use for this route', () => {
|
|
85
|
+
const root = tmpProject()
|
|
86
|
+
// Root route '/' slugs to 'home' — same as report.js's urlToSlug.
|
|
87
|
+
writeReport(root, 'home')
|
|
88
|
+
const result = writeStamp(root, { route: '/', mode: 'full' })
|
|
89
|
+
assert.equal(result.stamped, true)
|
|
90
|
+
fs.rmSync(root, { recursive: true, force: true })
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
test('a report saved for a different route does not satisfy the check', () => {
|
|
94
|
+
const root = tmpProject()
|
|
95
|
+
writeReport(root, 'about') // saved for /about, not /dashboard
|
|
96
|
+
const result = writeStamp(root, { route: '/dashboard', mode: 'full' })
|
|
97
|
+
assert.equal(result.stamped, false)
|
|
98
|
+
fs.rmSync(root, { recursive: true, force: true })
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
// ── Error-journal auto-resolve ───────────────────────────────────────────
|
|
102
|
+
|
|
103
|
+
test('writeStamp with a route marks unresolved error-journal entries for that route resolved', () => {
|
|
104
|
+
const root = tmpProject()
|
|
105
|
+
fs.mkdirSync(path.join(root, '.pulse'), { recursive: true })
|
|
106
|
+
fs.writeFileSync(path.join(root, '.pulse', 'errors.json'), JSON.stringify([
|
|
107
|
+
{ id: '1', ts: '2026-01-01T00:00:00.000Z', route: '/dashboard', phase: 'view', message: 'x', stack: '', resolved: false },
|
|
108
|
+
{ id: '2', ts: '2026-01-01T00:00:00.000Z', route: '/other', phase: 'view', message: 'y', stack: '', resolved: false },
|
|
109
|
+
]))
|
|
110
|
+
|
|
111
|
+
const result = writeStamp(root, { route: '/dashboard' })
|
|
112
|
+
assert.equal(result.stamped, true)
|
|
113
|
+
assert.equal(result.resolvedCount, 1)
|
|
114
|
+
|
|
115
|
+
const entries = JSON.parse(fs.readFileSync(path.join(root, '.pulse', 'errors.json'), 'utf8'))
|
|
116
|
+
assert.equal(entries.find(e => e.id === '1').resolved, true)
|
|
117
|
+
assert.equal(entries.find(e => e.id === '2').resolved, false, 'must not resolve entries for a different route')
|
|
118
|
+
fs.rmSync(root, { recursive: true, force: true })
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
// ── formatStampResult ────────────────────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
test('formatStampResult agentFacing:true on refusal tells the agent to retry pulse_stamp', () => {
|
|
124
|
+
const output = formatStampResult({ stamped: false, refused: true, reason: 'no report', resolvedCount: 0, ts: null }, { agentFacing: true })
|
|
125
|
+
assert.match(output, /Refusing to stamp/)
|
|
126
|
+
assert.match(output, /call pulse_stamp again/)
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
test('formatStampResult agentFacing:false (CLI) omits the pulse_stamp-specific retry phrasing', () => {
|
|
130
|
+
const output = formatStampResult({ stamped: false, refused: true, reason: 'no report', resolvedCount: 0, ts: null }, { agentFacing: false })
|
|
131
|
+
assert.match(output, /Refusing to stamp/)
|
|
132
|
+
assert.doesNotMatch(output, /pulse_stamp/, 'CLI output must not reference the MCP tool name pulse_stamp')
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
test('formatStampResult distinguishes a real write failure from a policy refusal', () => {
|
|
136
|
+
const output = formatStampResult({ stamped: false, refused: false, reason: 'disk full', resolvedCount: 0, ts: null }, { agentFacing: true })
|
|
137
|
+
assert.match(output, /Failed to stamp/)
|
|
138
|
+
assert.doesNotMatch(output, /Refusing to stamp/)
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
test('formatStampResult on success reports resolved count when nonzero', () => {
|
|
142
|
+
const output = formatStampResult({ stamped: true, refused: false, reason: null, resolvedCount: 2, ts: '123' }, { agentFacing: true })
|
|
143
|
+
assert.match(output, /2 error-journal entries marked resolved/)
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
test('formatStampResult on success says nothing about resolving when count is zero', () => {
|
|
147
|
+
const output = formatStampResult({ stamped: true, refused: false, reason: null, resolvedCount: 0, ts: '123' }, { agentFacing: true })
|
|
148
|
+
assert.doesNotMatch(output, /marked resolved/)
|
|
149
|
+
})
|
package/src/cli/start.js
CHANGED
|
@@ -43,6 +43,7 @@ if (!fs.existsSync(manifestPath)) {
|
|
|
43
43
|
|
|
44
44
|
let port = portArg !== -1 ? parseInt(args[portArg + 1], 10) : null
|
|
45
45
|
let defaultCache = null
|
|
46
|
+
let staticCache = null
|
|
46
47
|
let csp = null
|
|
47
48
|
let passthrough = {}
|
|
48
49
|
|
|
@@ -55,6 +56,7 @@ if (fs.existsSync(configPath)) {
|
|
|
55
56
|
const mod = await import(configPath)
|
|
56
57
|
port = port || mod.default?.port || null
|
|
57
58
|
defaultCache = mod.default?.defaultCache ?? null
|
|
59
|
+
staticCache = mod.default?.staticCache ?? null
|
|
58
60
|
csp = mod.default?.csp ?? null
|
|
59
61
|
for (const key of PASSTHROUGH_OPTIONS) {
|
|
60
62
|
if (mod.default?.[key] !== undefined) passthrough[key] = mod.default[key]
|
|
@@ -62,8 +64,18 @@ if (fs.existsSync(configPath)) {
|
|
|
62
64
|
} catch { /* ignore */ }
|
|
63
65
|
}
|
|
64
66
|
|
|
65
|
-
// Render (and other PaaS platforms) inject PORT — always takes precedence
|
|
66
|
-
port
|
|
67
|
+
// Render (and other PaaS platforms) inject PORT — always takes precedence, even
|
|
68
|
+
// over an explicit --port flag. This is correct for real deployment, but it also
|
|
69
|
+
// means a PORT env var set on a local dev machine for unrelated reasons (common
|
|
70
|
+
// in sandboxes, CI runners, some shell profiles) silently overrides the local
|
|
71
|
+
// convention of `pulse start --port 3001` documented for /verify — with no error,
|
|
72
|
+
// just a server on a port nobody asked for. Surface it instead of staying silent.
|
|
73
|
+
const requestedPort = port
|
|
74
|
+
const envPort = parseInt(process.env.PORT, 10) || null
|
|
75
|
+
port = envPort || port || 3000
|
|
76
|
+
if (envPort && requestedPort && envPort !== requestedPort) {
|
|
77
|
+
console.error(`⚠ PORT=${envPort} in the environment overrides --port ${requestedPort} — starting on ${envPort} instead.\n Unset PORT, or don't rely on --port locally while it's set.\n`)
|
|
78
|
+
}
|
|
67
79
|
|
|
68
80
|
// ---------------------------------------------------------------------------
|
|
69
81
|
// Start
|
|
@@ -84,6 +96,7 @@ createServer(specs, {
|
|
|
84
96
|
stream: true,
|
|
85
97
|
staticDir: PUBLIC_DIR,
|
|
86
98
|
defaultCache,
|
|
99
|
+
staticCache,
|
|
87
100
|
...(store ? { store } : {}),
|
|
88
101
|
...(csp ? { csp } : {}),
|
|
89
102
|
...passthrough,
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pulse — Spec validation (shared logic)
|
|
3
|
+
*
|
|
4
|
+
* The single source of truth for validating a Pulse spec's content. Used by
|
|
5
|
+
* both pulse_validate (the MCP tool, for an agent) and `pulse validate` (the
|
|
6
|
+
* CLI command, for a human running it directly). Neither wraps the other —
|
|
7
|
+
* they both call validateContent() here, so the two paths can never drift
|
|
8
|
+
* the way the checklist/workflow docs did before this file existed.
|
|
9
|
+
*
|
|
10
|
+
* Returns plain data — { valid, output, sourceWarnings } — with no MCP
|
|
11
|
+
* protocol coupling (no text()-wrapping) and no audience-specific framing
|
|
12
|
+
* (no "ask the user for design approval" — that's for the MCP tool's own
|
|
13
|
+
* wrapper to add, since a human at the terminal doesn't need it phrased at
|
|
14
|
+
* them that way).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import fs from 'fs'
|
|
18
|
+
import path from 'path'
|
|
19
|
+
import { execFileSync } from 'child_process'
|
|
20
|
+
|
|
21
|
+
const PROP_ALIASES = [
|
|
22
|
+
{ component: 'nav()', wrong: 'brand', correct: 'logo', note: 'The nav logo is set with the `logo` prop, not `brand`.' },
|
|
23
|
+
{ component: 'nav()', wrong: 'actions', correct: 'action', note: 'nav() takes a single `action` string (HTML), not an array.' },
|
|
24
|
+
{ component: 'footer()', wrong: 'items', correct: 'links', note: 'footer() top-level links use `links: [{ label, href }]`, not `items`.' },
|
|
25
|
+
{ component: 'footer()', wrong: 'nav', correct: 'links', note: 'footer() links prop is `links`, not `nav`.' },
|
|
26
|
+
{ component: 'input()', wrong: 'autocomplete', correct: 'attrs: { autocomplete }', note: 'HTML attributes not in the component API (autocomplete, min, max, step, pattern, inputmode, etc.) go inside the `attrs` object: input({ attrs: { autocomplete: "email" } }).' },
|
|
27
|
+
{ component: 'input()', wrong: 'maxlength', correct: 'attrs: { maxlength }', note: 'HTML attributes go inside `attrs`: input({ attrs: { maxlength: "100" } }).' },
|
|
28
|
+
{ component: 'input()', wrong: 'minlength', correct: 'attrs: { minlength }', note: 'HTML attributes go inside `attrs`: input({ attrs: { minlength: "2" } }).' },
|
|
29
|
+
{ component: 'input()', wrong: 'pattern', correct: 'attrs: { pattern }', note: 'HTML attributes go inside `attrs`: input({ attrs: { pattern: "[0-9]+" } }).' },
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
const EXTERNAL_IMG_HOSTS = [
|
|
33
|
+
{
|
|
34
|
+
pattern: /https?:\/\/images\.unsplash\.com/,
|
|
35
|
+
host: 'images.unsplash.com',
|
|
36
|
+
entry: 'https://images.unsplash.com',
|
|
37
|
+
cookieWarning: true,
|
|
38
|
+
name: 'Unsplash',
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
pattern: /https?:\/\/(?:fastly\.)?picsum\.photos/,
|
|
42
|
+
host: 'picsum.photos',
|
|
43
|
+
entry: 'https://picsum.photos https://fastly.picsum.photos',
|
|
44
|
+
cookieWarning: false,
|
|
45
|
+
name: 'picsum',
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
pattern: /https?:\/\/res\.cloudinary\.com/,
|
|
49
|
+
host: 'res.cloudinary.com',
|
|
50
|
+
entry: 'https://res.cloudinary.com',
|
|
51
|
+
cookieWarning: true,
|
|
52
|
+
name: 'Cloudinary',
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
pattern: /https?:\/\/cdn\.shopify\.com/,
|
|
56
|
+
host: 'cdn.shopify.com',
|
|
57
|
+
entry: 'https://cdn.shopify.com',
|
|
58
|
+
cookieWarning: false,
|
|
59
|
+
name: 'Shopify CDN',
|
|
60
|
+
},
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Validate spec source content against the schema plus a set of source-level
|
|
65
|
+
* structural checks (wrong component prop names, un-CSP'd external image hosts).
|
|
66
|
+
*
|
|
67
|
+
* @param {string} content - the spec file's source text
|
|
68
|
+
* @param {string} root - project root, used to find pulse.config.js
|
|
69
|
+
* @param {string} [tmpDir] - directory to write the temp validation file into,
|
|
70
|
+
* so relative imports in the spec resolve correctly. Defaults to <root>/src/pages.
|
|
71
|
+
* @returns {Promise<{ valid: boolean, timedOut: boolean, schemaOutput: string, sourceWarnings: string[] }>}
|
|
72
|
+
*/
|
|
73
|
+
export async function validateContent(content, root, tmpDir = null) {
|
|
74
|
+
tmpDir = tmpDir || path.join(root, 'src', 'pages')
|
|
75
|
+
|
|
76
|
+
const sourceWarnings = []
|
|
77
|
+
|
|
78
|
+
// Component prop alias checks — flag known wrong prop names
|
|
79
|
+
for (const { component, wrong, correct, note } of PROP_ALIASES) {
|
|
80
|
+
const fnName = component.replace('()', '')
|
|
81
|
+
const re = new RegExp(`${fnName}\\s*\\(\\s*\\{([^}]*)\\b${wrong}\\s*:`, 'g')
|
|
82
|
+
let m
|
|
83
|
+
while ((m = re.exec(content)) !== null) {
|
|
84
|
+
// attrs: { … } is the correct placement for HTML attributes — a match
|
|
85
|
+
// that falls inside an open attrs block is the recommended pattern,
|
|
86
|
+
// not a mistake.
|
|
87
|
+
if (/attrs\s*:\s*\{[^}]*$/.test(m[1])) continue
|
|
88
|
+
sourceWarnings.push(`"${wrong}" is not a recognised prop for ${component} — did you mean "${correct}"? ${note}`)
|
|
89
|
+
break
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// External image URL check — warn if the spec references an external image
|
|
94
|
+
// host not whitelisted in pulse.config.js's CSP img-src.
|
|
95
|
+
const configPath = path.join(root, 'pulse.config.js')
|
|
96
|
+
let configImgSrc = ''
|
|
97
|
+
if (fs.existsSync(configPath)) {
|
|
98
|
+
try { configImgSrc = fs.readFileSync(configPath, 'utf8') } catch { /* ignore */ }
|
|
99
|
+
}
|
|
100
|
+
for (const { pattern, host, entry, cookieWarning, name } of EXTERNAL_IMG_HOSTS) {
|
|
101
|
+
// Config match is by HOST, not full URL — CSP sources are valid without a
|
|
102
|
+
// scheme, so requiring https?:// in the config caused false positives.
|
|
103
|
+
if (pattern.test(content) && !configImgSrc.includes(host)) {
|
|
104
|
+
const cookieNote = cookieWarning
|
|
105
|
+
? `\n ⚠ ${name} sets tracking cookies that fail Lighthouse Best Practices regardless of CSP. For production, download images to public/images/ instead of linking to ${name} directly.`
|
|
106
|
+
: ''
|
|
107
|
+
sourceWarnings.push(
|
|
108
|
+
`External image host detected (${name}). Add it to csp.img-src in pulse.config.js before running Lighthouse:\n` +
|
|
109
|
+
` csp: { 'img-src': ['${entry}'] }\n` +
|
|
110
|
+
` Without this, images will be blocked and Lighthouse Best Practices will fail.${cookieNote}`
|
|
111
|
+
)
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
fs.mkdirSync(tmpDir, { recursive: true })
|
|
116
|
+
const tmpFile = path.join(tmpDir, `.pulse-validate-${Date.now()}.mjs`)
|
|
117
|
+
try {
|
|
118
|
+
fs.writeFileSync(tmpFile, content, 'utf8')
|
|
119
|
+
|
|
120
|
+
const validatorScript = new URL('../mcp/validate-worker.js', import.meta.url).pathname
|
|
121
|
+
let schemaOutput
|
|
122
|
+
let timedOut = false
|
|
123
|
+
try {
|
|
124
|
+
schemaOutput = execFileSync(process.execPath, [validatorScript, tmpFile], {
|
|
125
|
+
timeout: 10_000,
|
|
126
|
+
encoding: 'utf8',
|
|
127
|
+
}).trim()
|
|
128
|
+
} catch (err) {
|
|
129
|
+
timedOut = err.killed || err.signal === 'SIGTERM'
|
|
130
|
+
schemaOutput = timedOut
|
|
131
|
+
? 'Invalid: validation timed out — spec may have a hanging import or infinite loop'
|
|
132
|
+
: `Invalid: could not parse — ${err.stdout || err.message}`
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const valid = schemaOutput.startsWith('Valid ✓') && !schemaOutput.includes('Invalid')
|
|
136
|
+
|
|
137
|
+
return { valid, timedOut, schemaOutput, sourceWarnings }
|
|
138
|
+
} finally {
|
|
139
|
+
try { fs.unlinkSync(tmpFile) } catch { /* ignore */ }
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Validate a spec by file path — reads the file, validates from its own
|
|
145
|
+
* directory so relative imports resolve correctly for pages in subdirectories.
|
|
146
|
+
*
|
|
147
|
+
* @param {string} file - absolute path to the spec file
|
|
148
|
+
* @param {string} root - project root
|
|
149
|
+
* @returns {Promise<{ valid: boolean, timedOut: boolean, schemaOutput: string, sourceWarnings: string[] } | { error: string }>}
|
|
150
|
+
*/
|
|
151
|
+
export async function validateFile(file, root) {
|
|
152
|
+
if (!fs.existsSync(file)) {
|
|
153
|
+
return { error: `File not found: ${file}` }
|
|
154
|
+
}
|
|
155
|
+
const content = fs.readFileSync(file, 'utf8')
|
|
156
|
+
return validateContent(content, root, path.dirname(file))
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Format a validation result as human-readable text — shared rendering for
|
|
161
|
+
* both the CLI (printed to stdout) and the MCP tool (wrapped in text()).
|
|
162
|
+
* `nextSteps`, when true, appends the agent-facing browser-check reminder;
|
|
163
|
+
* a human running this from the terminal doesn't need that, so the CLI
|
|
164
|
+
* command omits it.
|
|
165
|
+
*/
|
|
166
|
+
export function formatValidationResult(result, { nextSteps = false } = {}) {
|
|
167
|
+
if (result.error) return result.error
|
|
168
|
+
|
|
169
|
+
let output = result.schemaOutput
|
|
170
|
+
if (result.sourceWarnings.length > 0) {
|
|
171
|
+
const propNotes = result.sourceWarnings.map(w => ` ⚠ ${w}`).join('\n')
|
|
172
|
+
if (output.startsWith('Valid ✓')) {
|
|
173
|
+
output = output.replace('Valid ✓', 'Valid ✓ — but fix these issues:') + '\n' + propNotes
|
|
174
|
+
} else {
|
|
175
|
+
output = output + '\n' + propNotes
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (nextSteps && result.valid && result.sourceWarnings.length === 0) {
|
|
180
|
+
output += '\n\n---\n**Next: browser check sequence** (do not skip)\n1. `pulse_fetch_page` → screenshot\n2. **New build?** Show screenshot to user and ask for design approval before continuing\n3. Once approved (or edit/fix): `pulse_design_review` (if intake ran) → `pulse_layout_review <url>` → `/verify`\nDo not run Lighthouse before the user approves the design. Do not report done until `/verify` passes.'
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return output
|
|
184
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pulse — validate.js tests
|
|
3
|
+
* run: node src/cli/validate.test.js
|
|
4
|
+
*
|
|
5
|
+
* This is the shared logic behind both pulse_validate (MCP tool) and
|
|
6
|
+
* `pulse validate` (CLI command) — a real end-to-end test here is the
|
|
7
|
+
* guarantee that a human running the CLI gets the exact same check an
|
|
8
|
+
* agent gets, since both call into this same module.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { test } from 'node:test'
|
|
12
|
+
import assert from 'node:assert/strict'
|
|
13
|
+
import fs from 'node:fs'
|
|
14
|
+
import os from 'node:os'
|
|
15
|
+
import path from 'node:path'
|
|
16
|
+
import { validateContent, validateFile, formatValidationResult } from './validate.js'
|
|
17
|
+
|
|
18
|
+
function tmpProject() {
|
|
19
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'pulse-validate-test-'))
|
|
20
|
+
fs.mkdirSync(path.join(root, 'src', 'pages'), { recursive: true })
|
|
21
|
+
return root
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
test('a valid spec with no meta.theme passes with a warning', async () => {
|
|
25
|
+
const root = tmpProject()
|
|
26
|
+
const content = `export default {\n route: '/x',\n meta: { title: 't', description: 'd' },\n view: () => '<main id="main-content">x</main>',\n}`
|
|
27
|
+
const result = await validateContent(content, root)
|
|
28
|
+
assert.equal(result.valid, true)
|
|
29
|
+
fs.rmSync(root, { recursive: true, force: true })
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
test('an invalid route (no leading slash) fails validation', async () => {
|
|
33
|
+
const root = tmpProject()
|
|
34
|
+
const content = `export default {\n route: 'no-slash',\n view: () => '',\n}`
|
|
35
|
+
const result = await validateContent(content, root)
|
|
36
|
+
assert.equal(result.valid, false)
|
|
37
|
+
assert.match(result.schemaOutput, /must start with "\/"/)
|
|
38
|
+
fs.rmSync(root, { recursive: true, force: true })
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
test('prop-alias check flags nav({ brand }) and suggests logo', async () => {
|
|
42
|
+
const root = tmpProject()
|
|
43
|
+
const content = `export default {\n route: '/x',\n view: () => nav({ brand: 'Site', links: [] }),\n}`
|
|
44
|
+
const result = await validateContent(content, root)
|
|
45
|
+
assert.equal(result.sourceWarnings.length, 1)
|
|
46
|
+
assert.match(result.sourceWarnings[0], /"brand" is not a recognised prop for nav\(\)/)
|
|
47
|
+
assert.match(result.sourceWarnings[0], /did you mean "logo"/)
|
|
48
|
+
fs.rmSync(root, { recursive: true, force: true })
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
test('prop-alias check does NOT flag autocomplete correctly placed inside attrs', async () => {
|
|
52
|
+
const root = tmpProject()
|
|
53
|
+
const content = `export default {\n route: '/x',\n view: () => input({ attrs: { autocomplete: 'email' } }),\n}`
|
|
54
|
+
const result = await validateContent(content, root)
|
|
55
|
+
assert.equal(result.sourceWarnings.length, 0,
|
|
56
|
+
`Expected no warnings for the recommended attrs pattern, got: ${JSON.stringify(result.sourceWarnings)}`)
|
|
57
|
+
fs.rmSync(root, { recursive: true, force: true })
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
test('external image host warning fires when unlisted in pulse.config.js CSP', async () => {
|
|
61
|
+
const root = tmpProject()
|
|
62
|
+
const content = `export default {\n route: '/x',\n view: () => \`<img src="https://images.unsplash.com/photo-1">\`,\n}`
|
|
63
|
+
const result = await validateContent(content, root)
|
|
64
|
+
assert.equal(result.sourceWarnings.length, 1)
|
|
65
|
+
assert.match(result.sourceWarnings[0], /External image host detected \(Unsplash\)/)
|
|
66
|
+
fs.rmSync(root, { recursive: true, force: true })
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
test('external image host warning is suppressed when the bare host is in pulse.config.js', async () => {
|
|
70
|
+
const root = tmpProject()
|
|
71
|
+
fs.writeFileSync(path.join(root, 'pulse.config.js'),
|
|
72
|
+
`export default { csp: { 'img-src': ['images.unsplash.com'] } }`)
|
|
73
|
+
const content = `export default {\n route: '/x',\n view: () => \`<img src="https://images.unsplash.com/photo-1">\`,\n}`
|
|
74
|
+
const result = await validateContent(content, root)
|
|
75
|
+
assert.equal(result.sourceWarnings.length, 0,
|
|
76
|
+
`Expected no warning when the bare host is already configured, got: ${JSON.stringify(result.sourceWarnings)}`)
|
|
77
|
+
fs.rmSync(root, { recursive: true, force: true })
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
test('inline <script> warning does NOT fire for external scripts with a src', async () => {
|
|
81
|
+
const root = tmpProject()
|
|
82
|
+
const view = [
|
|
83
|
+
'<script src="/menu.js" defer></script>',
|
|
84
|
+
'<script defer src="/a.js"></script>',
|
|
85
|
+
'<script type="module" src=\'/b.js\'></script>',
|
|
86
|
+
].join('')
|
|
87
|
+
const content = `export default {\n route: '/x',\n view: () => \`<main id="main-content">${view}</main>\`,\n}`
|
|
88
|
+
const result = await validateContent(content, root)
|
|
89
|
+
assert.equal(result.valid, true)
|
|
90
|
+
assert.doesNotMatch(result.schemaOutput, /Inline <script>/,
|
|
91
|
+
`Expected no inline-script warning for external scripts, got: ${result.schemaOutput}`)
|
|
92
|
+
fs.rmSync(root, { recursive: true, force: true })
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
test('inline <script> warning still fires for real inline scripts', async () => {
|
|
96
|
+
const root = tmpProject()
|
|
97
|
+
const content = `export default {\n route: '/x',\n view: () => \`<main id="main-content"><script>alert(1)</script></main>\`,\n}`
|
|
98
|
+
const result = await validateContent(content, root)
|
|
99
|
+
assert.match(result.schemaOutput, /Inline <script> block detected/)
|
|
100
|
+
fs.rmSync(root, { recursive: true, force: true })
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
test('inline <script> warning still fires when only data-src is present', async () => {
|
|
104
|
+
const root = tmpProject()
|
|
105
|
+
const content = `export default {\n route: '/x',\n view: () => \`<main id="main-content"><script data-src="/a.js">alert(1)</script></main>\`,\n}`
|
|
106
|
+
const result = await validateContent(content, root)
|
|
107
|
+
assert.match(result.schemaOutput, /Inline <script> block detected/)
|
|
108
|
+
fs.rmSync(root, { recursive: true, force: true })
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
test('validateFile resolves relative imports from the spec\'s own directory', async () => {
|
|
112
|
+
const root = tmpProject()
|
|
113
|
+
fs.mkdirSync(path.join(root, 'src', 'components'), { recursive: true })
|
|
114
|
+
fs.mkdirSync(path.join(root, 'src', 'pages', 'news'), { recursive: true })
|
|
115
|
+
fs.writeFileSync(path.join(root, 'src', 'components', 'layout.js'),
|
|
116
|
+
`export const layout = ({ content }) => content`)
|
|
117
|
+
const specPath = path.join(root, 'src', 'pages', 'news', 'index.js')
|
|
118
|
+
fs.writeFileSync(specPath,
|
|
119
|
+
`import { layout } from '../../components/layout.js'\nexport default {\n route: '/news',\n meta: { title: 't', description: 'd' },\n view: () => layout({ content: '<main id="main-content">news</main>' }),\n}`)
|
|
120
|
+
const result = await validateFile(specPath, root)
|
|
121
|
+
assert.equal(result.valid, true,
|
|
122
|
+
`Expected relative import to resolve from the spec's own directory, got: ${JSON.stringify(result)}`)
|
|
123
|
+
fs.rmSync(root, { recursive: true, force: true })
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
test('validateFile returns an error object for a missing file', async () => {
|
|
127
|
+
const root = tmpProject()
|
|
128
|
+
const result = await validateFile(path.join(root, 'does-not-exist.js'), root)
|
|
129
|
+
assert.match(result.error, /File not found/)
|
|
130
|
+
fs.rmSync(root, { recursive: true, force: true })
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
test('formatValidationResult with nextSteps: true appends the agent browser-check reminder', async () => {
|
|
134
|
+
const root = tmpProject()
|
|
135
|
+
const content = `export default {\n route: '/x',\n meta: { title: 't', description: 'd', theme: 'dark' },\n view: () => '<main id="main-content">x</main>',\n}`
|
|
136
|
+
const result = await validateContent(content, root)
|
|
137
|
+
const withNextSteps = formatValidationResult(result, { nextSteps: true })
|
|
138
|
+
const without = formatValidationResult(result, { nextSteps: false })
|
|
139
|
+
assert.match(withNextSteps, /browser check sequence/)
|
|
140
|
+
assert.doesNotMatch(without, /browser check sequence/,
|
|
141
|
+
'CLI output (nextSteps: false, the default) must not carry agent-only framing like "ask the user for design approval"')
|
|
142
|
+
fs.rmSync(root, { recursive: true, force: true })
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
test('formatValidationResult merges source warnings into a clean schema pass', async () => {
|
|
146
|
+
const root = tmpProject()
|
|
147
|
+
const content = `export default {\n route: '/x',\n meta: { title: 't', description: 'd', theme: 'dark' },\n view: () => nav({ brand: 'x', links: [] }),\n}`
|
|
148
|
+
const result = await validateContent(content, root)
|
|
149
|
+
const output = formatValidationResult(result)
|
|
150
|
+
assert.match(output, /Valid ✓ — but fix these issues:/)
|
|
151
|
+
assert.match(output, /did you mean "logo"/)
|
|
152
|
+
fs.rmSync(root, { recursive: true, force: true })
|
|
153
|
+
})
|