@invisibleloop/pulse 0.20.38 → 0.20.39
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 +1 -1
- package/public/.pulse-ui-version +1 -1
- package/scripts/css-purge.test.js +0 -147
- package/scripts/strip-server.test.js +0 -491
- package/src/cli/check-bundles.test.js +0 -137
- package/src/cli/cli.test.js +0 -341
- package/src/cli/diagnose.test.js +0 -124
- package/src/cli/process-utils.test.js +0 -114
- package/src/cli/review.test.js +0 -143
- package/src/cli/stamp.test.js +0 -149
- package/src/cli/validate.test.js +0 -153
- package/src/mcp/guards.test.js +0 -90
- package/src/mcp/mcp.test.js +0 -617
- package/src/md/md.test.js +0 -217
- package/src/runtime/image.test.js +0 -111
- package/src/runtime/morph.test.js +0 -317
- package/src/runtime/navigate.test.js +0 -304
- package/src/runtime/runtime.test.js +0 -1069
- package/src/runtime/ssr.test.js +0 -750
- package/src/server/server.test.js +0 -2474
- package/src/spec/schema.test.js +0 -467
- package/src/store/store.test.js +0 -210
- package/src/testing/testing.test.js +0 -508
- package/src/ui/ui.test.js +0 -1157
|
@@ -1,114 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Pulse — process-utils.js tests
|
|
3
|
-
* run: node src/cli/process-utils.test.js
|
|
4
|
-
*
|
|
5
|
-
* killPulseServerOnPort must never kill a process it didn't spawn as a
|
|
6
|
-
* Pulse dev/prod server — see its doc comment for the real user report
|
|
7
|
-
* (an unrelated Next.js dev server on port 3000 was at risk of being
|
|
8
|
-
* killed by a blind lsof|kill) this fixes.
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
import { test } from 'node:test'
|
|
12
|
-
import assert from 'node:assert/strict'
|
|
13
|
-
import { spawn } from 'node:child_process'
|
|
14
|
-
import net from 'node:net'
|
|
15
|
-
import { killPulseServerOnPort } from './process-utils.js'
|
|
16
|
-
|
|
17
|
-
/** Find a free TCP port by binding to port 0 and reading it back. */
|
|
18
|
-
async function freePort() {
|
|
19
|
-
return new Promise((resolve, reject) => {
|
|
20
|
-
const srv = net.createServer()
|
|
21
|
-
srv.listen(0, () => {
|
|
22
|
-
const { port } = srv.address()
|
|
23
|
-
srv.close(() => resolve(port))
|
|
24
|
-
})
|
|
25
|
-
srv.on('error', reject)
|
|
26
|
-
})
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
/** Spawn a plain (non-Pulse) process holding a port open, for negative tests. */
|
|
30
|
-
function spawnPlainListener(port) {
|
|
31
|
-
const proc = spawn(process.execPath, ['-e', `
|
|
32
|
-
const net = require('net')
|
|
33
|
-
net.createServer().listen(${port})
|
|
34
|
-
setInterval(() => {}, 1000) // keep the process alive
|
|
35
|
-
`])
|
|
36
|
-
return proc
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
async function waitUntilListening(port, maxMs = 3000) {
|
|
40
|
-
const deadline = Date.now() + maxMs
|
|
41
|
-
while (Date.now() < deadline) {
|
|
42
|
-
const alive = await new Promise(resolve => {
|
|
43
|
-
const sock = net.createConnection(port, '127.0.0.1')
|
|
44
|
-
sock.on('connect', () => { sock.destroy(); resolve(true) })
|
|
45
|
-
sock.on('error', () => resolve(false))
|
|
46
|
-
})
|
|
47
|
-
if (alive) return true
|
|
48
|
-
await new Promise(r => setTimeout(r, 50))
|
|
49
|
-
}
|
|
50
|
-
return false
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
async function waitUntilFree(port, maxMs = 3000) {
|
|
54
|
-
const deadline = Date.now() + maxMs
|
|
55
|
-
while (Date.now() < deadline) {
|
|
56
|
-
const alive = await new Promise(resolve => {
|
|
57
|
-
const sock = net.createConnection(port, '127.0.0.1')
|
|
58
|
-
sock.on('connect', () => { sock.destroy(); resolve(true) })
|
|
59
|
-
sock.on('error', () => resolve(false))
|
|
60
|
-
})
|
|
61
|
-
if (!alive) return true
|
|
62
|
-
await new Promise(r => setTimeout(r, 50))
|
|
63
|
-
}
|
|
64
|
-
return false
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
test('killPulseServerOnPort does not kill a process whose command line has no Pulse marker', async () => {
|
|
68
|
-
const port = await freePort()
|
|
69
|
-
const proc = spawnPlainListener(port)
|
|
70
|
-
try {
|
|
71
|
-
assert.equal(await waitUntilListening(port), true, 'test listener never came up')
|
|
72
|
-
|
|
73
|
-
const result = killPulseServerOnPort(port)
|
|
74
|
-
|
|
75
|
-
assert.equal(result.killed.length, 0, 'must not report the unrelated process as killed')
|
|
76
|
-
assert.equal(result.skipped.length, 1, 'must report exactly one skipped process')
|
|
77
|
-
assert.equal(result.skipped[0].pid, String(proc.pid))
|
|
78
|
-
// The process must actually still be alive — not just unreported as killed.
|
|
79
|
-
assert.equal(await waitUntilFree(port, 300), false, 'the unrelated process must still be listening')
|
|
80
|
-
} finally {
|
|
81
|
-
proc.kill('SIGKILL')
|
|
82
|
-
}
|
|
83
|
-
})
|
|
84
|
-
|
|
85
|
-
test('killPulseServerOnPort kills a process whose command line matches a Pulse marker', async () => {
|
|
86
|
-
const port = await freePort()
|
|
87
|
-
// Simulate a Pulse dev server by giving the child process's argv a marker
|
|
88
|
-
// substring (cli/dev.js) without actually running the real dev server —
|
|
89
|
-
// exercises the same ps-based matching path a real `node .../cli/dev.js`
|
|
90
|
-
// invocation would hit.
|
|
91
|
-
const proc = spawn(process.execPath, ['-e', `
|
|
92
|
-
const net = require('net')
|
|
93
|
-
net.createServer().listen(${port})
|
|
94
|
-
setInterval(() => {}, 1000)
|
|
95
|
-
`, '--', 'cli/dev.js'])
|
|
96
|
-
try {
|
|
97
|
-
assert.equal(await waitUntilListening(port), true, 'test listener never came up')
|
|
98
|
-
|
|
99
|
-
const result = killPulseServerOnPort(port)
|
|
100
|
-
|
|
101
|
-
assert.equal(result.skipped.length, 0, 'must not skip a process matching a Pulse marker')
|
|
102
|
-
assert.equal(result.killed.length, 1)
|
|
103
|
-
assert.equal(result.killed[0], String(proc.pid))
|
|
104
|
-
assert.equal(await waitUntilFree(port), true, 'the matched process must actually be killed')
|
|
105
|
-
} finally {
|
|
106
|
-
try { proc.kill('SIGKILL') } catch { /* already dead */ }
|
|
107
|
-
}
|
|
108
|
-
})
|
|
109
|
-
|
|
110
|
-
test('killPulseServerOnPort on a free port returns empty killed and skipped', async () => {
|
|
111
|
-
const port = await freePort()
|
|
112
|
-
const result = killPulseServerOnPort(port)
|
|
113
|
-
assert.deepEqual(result, { killed: [], skipped: [] })
|
|
114
|
-
})
|
package/src/cli/review.test.js
DELETED
|
@@ -1,143 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Pulse — review.js tests
|
|
3
|
-
* run: node src/cli/review.test.js
|
|
4
|
-
*
|
|
5
|
-
* Shared logic behind pulse_review (MCP tool) and `pulse review` (CLI
|
|
6
|
-
* command). The MCP tool's output talks directly to an agent ("you are now
|
|
7
|
-
* a senior code reviewer") — these tests specifically confirm that framing
|
|
8
|
-
* does NOT leak into the CLI's human-facing output, since that was the main
|
|
9
|
-
* risk in this extraction.
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
|
-
import { test } from 'node:test'
|
|
13
|
-
import assert from 'node:assert/strict'
|
|
14
|
-
import fs from 'node:fs'
|
|
15
|
-
import os from 'node:os'
|
|
16
|
-
import path from 'node:path'
|
|
17
|
-
import { runQuickReview, formatQuickReview, runFullReview, formatFullReview } from './review.js'
|
|
18
|
-
|
|
19
|
-
function tmpProject() {
|
|
20
|
-
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'pulse-review-test-'))
|
|
21
|
-
fs.mkdirSync(path.join(root, 'src', 'pages'), { recursive: true })
|
|
22
|
-
return root
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
function writeSpec(root, name, content) {
|
|
26
|
-
const file = path.join(root, 'src', 'pages', name)
|
|
27
|
-
fs.writeFileSync(file, content)
|
|
28
|
-
return file
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
// ── Quick mode ────────────────────────────────────────────────────────────
|
|
32
|
-
|
|
33
|
-
test('quick review flags a modalOpen state pattern', async () => {
|
|
34
|
-
const root = tmpProject()
|
|
35
|
-
const file = writeSpec(root, 'bad.js',
|
|
36
|
-
`export default {\n route: '/x',\n state: { modalOpen: false },\n view: (state) => \`<main id="main-content">\${state.modalOpen ? 'open' : ''}</main>\`,\n}`)
|
|
37
|
-
const source = fs.readFileSync(file, 'utf8')
|
|
38
|
-
const result = await runQuickReview(source, file)
|
|
39
|
-
assert.equal(result.issues.some(i => i.includes('modalOpen')), true)
|
|
40
|
-
fs.rmSync(root, { recursive: true, force: true })
|
|
41
|
-
})
|
|
42
|
-
|
|
43
|
-
test('quick review flags a hex colour in rendered HTML', async () => {
|
|
44
|
-
const root = tmpProject()
|
|
45
|
-
const file = writeSpec(root, 'bad.js',
|
|
46
|
-
`export default {\n route: '/x',\n state: {},\n view: () => '<main id="main-content"><h1 style="color:#ff0000">hi</h1></main>',\n}`)
|
|
47
|
-
const source = fs.readFileSync(file, 'utf8')
|
|
48
|
-
const result = await runQuickReview(source, file)
|
|
49
|
-
assert.equal(result.warnings.some(w => w.includes('hex')), true)
|
|
50
|
-
fs.rmSync(root, { recursive: true, force: true })
|
|
51
|
-
})
|
|
52
|
-
|
|
53
|
-
test('quick review reports no issues for a clean spec', async () => {
|
|
54
|
-
const root = tmpProject()
|
|
55
|
-
const file = writeSpec(root, 'good.js',
|
|
56
|
-
`export default {\n route: '/x',\n state: {},\n view: () => '<main id="main-content"><h1>hi</h1></main>',\n}`)
|
|
57
|
-
const source = fs.readFileSync(file, 'utf8')
|
|
58
|
-
const result = await runQuickReview(source, file)
|
|
59
|
-
assert.equal(result.issues.length, 0)
|
|
60
|
-
fs.rmSync(root, { recursive: true, force: true })
|
|
61
|
-
})
|
|
62
|
-
|
|
63
|
-
test('formatQuickReview agentFacing:true tells the agent to run pulse_validate then /verify', () => {
|
|
64
|
-
const output = formatQuickReview({ issues: [], warnings: [], confirms: [] }, { agentFacing: true })
|
|
65
|
-
assert.match(output, /pulse_validate/)
|
|
66
|
-
assert.match(output, /\/verify/)
|
|
67
|
-
})
|
|
68
|
-
|
|
69
|
-
test('formatQuickReview agentFacing:false (CLI default) uses plain human phrasing', () => {
|
|
70
|
-
const output = formatQuickReview({ issues: [], warnings: [], confirms: [] }, { agentFacing: false })
|
|
71
|
-
assert.match(output, /pulse validate/)
|
|
72
|
-
assert.doesNotMatch(output, /pulse_validate/, 'CLI output must not reference the MCP tool name pulse_validate')
|
|
73
|
-
assert.doesNotMatch(output, /\/verify/, 'CLI output must not reference the /verify slash command')
|
|
74
|
-
})
|
|
75
|
-
|
|
76
|
-
// ── Full mode ─────────────────────────────────────────────────────────────
|
|
77
|
-
|
|
78
|
-
test('full review runs the schema validator and reports auto-checks', async () => {
|
|
79
|
-
const root = tmpProject()
|
|
80
|
-
const file = writeSpec(root, 'good.js',
|
|
81
|
-
`export default {\n route: '/x',\n meta: { title: 't', description: 'd', theme: 'dark' },\n view: () => '<main id="main-content"><h1>hi</h1></main>',\n}`)
|
|
82
|
-
const source = fs.readFileSync(file, 'utf8')
|
|
83
|
-
const result = await runFullReview(source, file)
|
|
84
|
-
assert.match(result.validationResult, /Valid ✓/)
|
|
85
|
-
assert.equal(result.autoChecks.length > 0, true)
|
|
86
|
-
fs.rmSync(root, { recursive: true, force: true })
|
|
87
|
-
})
|
|
88
|
-
|
|
89
|
-
test('full review auto-checks flag modalOpen and malformed _storeUpdate', async () => {
|
|
90
|
-
const root = tmpProject()
|
|
91
|
-
const file = writeSpec(root, 'bad.js',
|
|
92
|
-
`export default {\n route: '/x',\n state: { modalOpen: false },\n actions: {\n save: {\n onSuccess: (state) => ({ _storeUpdate: true }),\n onError: (state, err) => ({ error: err.message }),\n run: async () => {},\n },\n },\n view: () => '<main id="main-content">x</main>',\n}`)
|
|
93
|
-
const source = fs.readFileSync(file, 'utf8')
|
|
94
|
-
const result = await runFullReview(source, file)
|
|
95
|
-
assert.equal(result.autoChecks.some(c => c.includes('modalOpen')), true)
|
|
96
|
-
assert.equal(result.autoChecks.some(c => c.includes('_storeUpdate')), true)
|
|
97
|
-
fs.rmSync(root, { recursive: true, force: true })
|
|
98
|
-
})
|
|
99
|
-
|
|
100
|
-
test('formatFullReview agentFacing:true opens with "you are now a senior code reviewer"', async () => {
|
|
101
|
-
const root = tmpProject()
|
|
102
|
-
const file = writeSpec(root, 'good.js',
|
|
103
|
-
`export default {\n route: '/x',\n meta: { title: 't', description: 'd', theme: 'dark' },\n view: () => '<main id="main-content">x</main>',\n}`)
|
|
104
|
-
const source = fs.readFileSync(file, 'utf8')
|
|
105
|
-
const result = await runFullReview(source, file)
|
|
106
|
-
const output = formatFullReview(result, { source, file, agentFacing: true })
|
|
107
|
-
assert.match(output, /you are now a \*\*senior code reviewer\*\*/i)
|
|
108
|
-
assert.match(output, /Continue to the verification workflow/)
|
|
109
|
-
fs.rmSync(root, { recursive: true, force: true })
|
|
110
|
-
})
|
|
111
|
-
|
|
112
|
-
test('formatFullReview agentFacing:false (CLI default) never says "you are now a reviewer" or references agent-only workflow', async () => {
|
|
113
|
-
const root = tmpProject()
|
|
114
|
-
const file = writeSpec(root, 'good.js',
|
|
115
|
-
`export default {\n route: '/x',\n meta: { title: 't', description: 'd', theme: 'dark' },\n view: () => '<main id="main-content">x</main>',\n}`)
|
|
116
|
-
const source = fs.readFileSync(file, 'utf8')
|
|
117
|
-
const result = await runFullReview(source, file)
|
|
118
|
-
const output = formatFullReview(result, { source, file, agentFacing: false })
|
|
119
|
-
assert.doesNotMatch(output, /you are now a/i,
|
|
120
|
-
'CLI output must not carry the agent-directive "you are now a reviewer" framing')
|
|
121
|
-
assert.doesNotMatch(output, /Continue to the verification workflow/,
|
|
122
|
-
'CLI output must not tell a human to "continue to the verification workflow" — that instruction is for an agent session')
|
|
123
|
-
assert.match(output, new RegExp(`Spec: ${file.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`),
|
|
124
|
-
'CLI output should plainly name the file being reviewed')
|
|
125
|
-
fs.rmSync(root, { recursive: true, force: true })
|
|
126
|
-
})
|
|
127
|
-
|
|
128
|
-
test('the checklist reference material is identical in both agentFacing modes', async () => {
|
|
129
|
-
const root = tmpProject()
|
|
130
|
-
const file = writeSpec(root, 'good.js',
|
|
131
|
-
`export default {\n route: '/x',\n meta: { title: 't', description: 'd', theme: 'dark' },\n view: () => '<main id="main-content">x</main>',\n}`)
|
|
132
|
-
const source = fs.readFileSync(file, 'utf8')
|
|
133
|
-
const result = await runFullReview(source, file)
|
|
134
|
-
const agentOutput = formatFullReview(result, { source, file, agentFacing: true })
|
|
135
|
-
const humanOutput = formatFullReview(result, { source, file, agentFacing: false })
|
|
136
|
-
// Both must contain the same checklist section headers — only the framing
|
|
137
|
-
// around them (intro/closing) should differ, never the checklist itself.
|
|
138
|
-
for (const heading of ['### Structure', '### Mutations & actions', '### Components & HTML', '### Accessibility', '### Defensive coding']) {
|
|
139
|
-
assert.equal(agentOutput.includes(heading), true, `agent output missing ${heading}`)
|
|
140
|
-
assert.equal(humanOutput.includes(heading), true, `human output missing ${heading}`)
|
|
141
|
-
}
|
|
142
|
-
fs.rmSync(root, { recursive: true, force: true })
|
|
143
|
-
})
|
package/src/cli/stamp.test.js
DELETED
|
@@ -1,149 +0,0 @@
|
|
|
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/validate.test.js
DELETED
|
@@ -1,153 +0,0 @@
|
|
|
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
|
-
})
|
package/src/mcp/guards.test.js
DELETED
|
@@ -1,90 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* MCP input guard tests — run with: node src/mcp/guards.test.js
|
|
3
|
-
*/
|
|
4
|
-
|
|
5
|
-
import fs from 'node:fs'
|
|
6
|
-
import os from 'node:os'
|
|
7
|
-
import path from 'node:path'
|
|
8
|
-
import assert from 'node:assert/strict'
|
|
9
|
-
import { resolveInRoot, isLoopbackUrl, isPrivateAddress, assertPublicUrl } from './guards.js'
|
|
10
|
-
|
|
11
|
-
let passed = 0
|
|
12
|
-
let failed = 0
|
|
13
|
-
|
|
14
|
-
async function test(label, fn) {
|
|
15
|
-
try { await fn(); console.log(` ✓ ${label}`); passed++ }
|
|
16
|
-
catch (e) { console.log(` ✗ ${label}\n ${e.message}`); failed++ }
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
const base = fs.mkdtempSync(path.join(os.tmpdir(), 'pulse-guards-'))
|
|
20
|
-
const root = path.join(base, 'project')
|
|
21
|
-
const outside = path.join(base, 'outside')
|
|
22
|
-
fs.mkdirSync(path.join(root, 'src', 'pages'), { recursive: true })
|
|
23
|
-
fs.mkdirSync(outside)
|
|
24
|
-
fs.writeFileSync(path.join(root, 'src', 'pages', 'a.js'), '')
|
|
25
|
-
fs.writeFileSync(path.join(outside, 'secret.js'), '')
|
|
26
|
-
fs.symlinkSync(outside, path.join(root, 'link'))
|
|
27
|
-
|
|
28
|
-
console.log('\nresolveInRoot\n')
|
|
29
|
-
|
|
30
|
-
await test('accepts absolute and relative paths inside the project', () => {
|
|
31
|
-
assert.equal(resolveInRoot(root, path.join(root, 'src/pages/a.js')), path.join(root, 'src/pages/a.js'))
|
|
32
|
-
assert.equal(resolveInRoot(root, 'src/pages/a.js'), path.join(root, 'src/pages/a.js'))
|
|
33
|
-
})
|
|
34
|
-
await test('accepts a not-yet-created file inside the project', () => {
|
|
35
|
-
assert.doesNotThrow(() => resolveInRoot(root, 'src/pages/new.js'))
|
|
36
|
-
})
|
|
37
|
-
await test('rejects absolute paths outside the project', () => {
|
|
38
|
-
assert.throws(() => resolveInRoot(root, path.join(outside, 'secret.js')), /inside the project/)
|
|
39
|
-
})
|
|
40
|
-
await test('rejects ../ traversal', () => {
|
|
41
|
-
assert.throws(() => resolveInRoot(root, '../outside/secret.js'), /inside the project/)
|
|
42
|
-
assert.throws(() => resolveInRoot(root, 'src/../../outside/secret.js'), /inside the project/)
|
|
43
|
-
})
|
|
44
|
-
await test('rejects a symlink inside the project that points outside it', () => {
|
|
45
|
-
assert.throws(() => resolveInRoot(root, 'link/secret.js'), /inside the project/)
|
|
46
|
-
})
|
|
47
|
-
await test('rejects a sibling directory sharing the root name prefix', () => {
|
|
48
|
-
fs.mkdirSync(root + '-evil')
|
|
49
|
-
assert.throws(() => resolveInRoot(root, root + '-evil/x.js'), /inside the project/)
|
|
50
|
-
})
|
|
51
|
-
|
|
52
|
-
console.log('\nisLoopbackUrl\n')
|
|
53
|
-
|
|
54
|
-
await test('allows localhost, 127.x and ::1', () => {
|
|
55
|
-
for (const u of ['http://localhost:3000/', 'http://127.0.0.1:3000/x', 'http://[::1]:3000/', 'http://app.localhost/'])
|
|
56
|
-
assert.equal(isLoopbackUrl(u), true, u)
|
|
57
|
-
})
|
|
58
|
-
await test('rejects remote hosts, lookalikes and non-http schemes', () => {
|
|
59
|
-
for (const u of ['http://example.com/', 'http://localhost.evil.com/', 'http://169.254.169.254/', 'file:///etc/passwd', 'ftp://localhost/', 'nonsense'])
|
|
60
|
-
assert.equal(isLoopbackUrl(u), false, u)
|
|
61
|
-
})
|
|
62
|
-
|
|
63
|
-
console.log('\nisPrivateAddress / assertPublicUrl\n')
|
|
64
|
-
|
|
65
|
-
await test('flags loopback, private, link-local, CGNAT and metadata addresses', () => {
|
|
66
|
-
for (const ip of ['127.0.0.1', '10.0.0.5', '172.16.0.1', '172.31.255.255', '192.168.1.1', '169.254.169.254', '100.64.0.1', '0.0.0.0', '::1', '::', 'fd00::1', 'fe80::1', '::ffff:10.0.0.1'])
|
|
67
|
-
assert.equal(isPrivateAddress(ip), true, ip)
|
|
68
|
-
})
|
|
69
|
-
await test('allows public addresses', () => {
|
|
70
|
-
for (const ip of ['8.8.8.8', '1.1.1.1', '172.32.0.1', '2606:4700:4700::1111'])
|
|
71
|
-
assert.equal(isPrivateAddress(ip), false, ip)
|
|
72
|
-
})
|
|
73
|
-
await test('assertPublicUrl rejects internal IP literals and non-http schemes', async () => {
|
|
74
|
-
await assert.rejects(assertPublicUrl('http://169.254.169.254/latest/meta-data/'), /not a public address/)
|
|
75
|
-
await assert.rejects(assertPublicUrl('http://127.0.0.1:3000/'), /not a public address/)
|
|
76
|
-
await assert.rejects(assertPublicUrl('http://[::1]/'), /not a public address/)
|
|
77
|
-
await assert.rejects(assertPublicUrl('file:///etc/passwd'), /only http/)
|
|
78
|
-
})
|
|
79
|
-
await test('assertPublicUrl rejects hostnames that resolve to loopback', async () => {
|
|
80
|
-
await assert.rejects(assertPublicUrl('http://localhost:3000/'), /non-public/)
|
|
81
|
-
})
|
|
82
|
-
await test('assertPublicUrl accepts a public IP literal', async () => {
|
|
83
|
-
await assertPublicUrl('https://1.1.1.1/')
|
|
84
|
-
})
|
|
85
|
-
|
|
86
|
-
fs.rmSync(base, { recursive: true, force: true })
|
|
87
|
-
fs.rmSync(root + '-evil', { recursive: true, force: true })
|
|
88
|
-
|
|
89
|
-
console.log(`\n${passed + failed} tests: ${passed} passed, ${failed} failed\n`)
|
|
90
|
-
if (failed > 0) process.exit(1)
|