@abarcenas/ai-workflow-template 1.44.0 → 1.46.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/config/README.md +43 -0
- package/.agents/config/domain.md +37 -0
- package/.agents/instructions/agent.instructions.md +10 -10
- package/.agents/instructions/learned-knowledge.instructions.md +170 -0
- package/.agents/instructions/memory-bank.instructions.md +11 -0
- package/.agents/skills/caveman-compress/README.md +2 -0
- package/.agents/skills/caveman-compress/SECURITY.md +3 -1
- package/.agents/skills/caveman-compress/SKILL.md +4 -0
- package/.agents/skills/caveman-compress/scripts/compress.py +9 -2
- package/.agents/skills/compress/SKILL.md +4 -0
- package/.agents/skills/compress/scripts/compress.py +9 -2
- package/.agents/skills/domain-modeling/ADR-FORMAT.md +47 -0
- package/.agents/skills/domain-modeling/GLOSSARY-FORMAT.md +60 -0
- package/.agents/skills/domain-modeling/SKILL.md +74 -0
- package/.agents/skills/grill-me/SKILL.md +7 -0
- package/.agents/skills/grill-with-docs/SKILL.md +7 -0
- package/.agents/skills/grilling/SKILL.md +28 -0
- package/.agents/skills/prototype/LOGIC.md +67 -0
- package/.agents/skills/prototype/SKILL.md +26 -0
- package/.agents/skills/prototype/UI.md +112 -0
- package/.github/workflows/npm-publish.yml +10 -0
- package/.github/workflows/pr-test.yml +31 -0
- package/.opencode/agents/architect.agent.md +0 -1
- package/.opencode/agents/coder.agent.md +0 -1
- package/.opencode/agents/deployer.agent.md +0 -1
- package/.opencode/agents/designer.agent.md +0 -1
- package/.opencode/agents/e2e-tester.agent.md +0 -1
- package/.opencode/agents/implementer.agent.md +0 -1
- package/.opencode/agents/orchestrator/feature-pipeline.agent.md +0 -1
- package/.opencode/agents/orchestrator/orchestrator.agent.md +68 -4
- package/.opencode/agents/orchestrator/tdd-orchestrator.agent.md +0 -1
- package/.opencode/agents/plan.agent.md +0 -1
- package/.opencode/agents/researcher.agent.md +0 -1
- package/.opencode/agents/reviewer.agent.md +0 -1
- package/.opencode/agents/scope.agent.md +299 -0
- package/.opencode/agents/tracker.agent.md +0 -1
- package/.opencode/agents/unit-tester.agent.md +0 -1
- package/AGENTS.md +44 -5
- package/README.md +6 -3
- package/package.json +1 -1
- package/scripts/bump-version.js +7 -0
- package/scripts/bump-version.test.js +238 -0
- package/scripts/mcp/playwright-mcp-launcher.js +55 -29
- package/scripts/mcp/playwright-mcp-launcher.test.js +217 -0
- package/scripts/mcp-memory-server.js +93 -76
- package/scripts/mcp-memory-server.test.js +398 -0
- package/scripts/memory-cli.test.js +297 -0
- package/scripts/memory-index.js +119 -31
- package/scripts/memory-index.test.js +1173 -0
- package/scripts/normalize-memory.js +26 -1
- package/scripts/normalize-memory.test.js +753 -0
- package/scripts/setup/discover.test.js +124 -2
- package/scripts/setup/hooks.test.js +188 -1
- package/scripts/setup/husky-init.test.js +311 -0
- package/scripts/setup/index.js +35 -5
- package/scripts/setup/index.test.js +279 -0
- package/scripts/setup/knowledgebase.test.js +100 -0
- package/scripts/setup/skills-phase.js +568 -0
- package/scripts/setup/skills-phase.test.js +1274 -0
- package/scripts/setup/sync-phase.js +6 -1
- package/scripts/setup/sync-phase.test.js +180 -2
- package/scripts/setup/ui.test.js +579 -0
- package/scripts/setup/utils.test.js +586 -0
- package/scripts/sync-unit.test.js +1342 -0
- package/scripts/sync.js +540 -246
- package/scripts/sync.test.js +283 -0
- package/scripts/vocab-sync.test.js +88 -0
- package/skills-lock.json +90 -0
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
2
|
+
// bump-version.test.js — Unit tests for the pre-commit version bumper
|
|
3
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
4
|
+
//
|
|
5
|
+
// bump-version.js is a top-level script: it reads package.json on import,
|
|
6
|
+
// derives the previous major version from `git show HEAD:package.json`, writes
|
|
7
|
+
// the new version, and stages the file. Tests therefore mock `fs` and
|
|
8
|
+
// `child_process` so the repository's real package.json is never touched, then
|
|
9
|
+
// re-import the module for each scenario with `vi.resetModules()`.
|
|
10
|
+
//
|
|
11
|
+
// Covered scenarios:
|
|
12
|
+
// 1. SKIP_BUMP=1 → logs skip, exits 0, no fs/git calls
|
|
13
|
+
// 2. SKIP_BUMP=yes → same (any non-empty value is truthy)
|
|
14
|
+
// 3. SKIP_BUMP=0 → same (string "0" is truthy in Node)
|
|
15
|
+
// 4. SKIP_BUMP="" → falsy, bump proceeds
|
|
16
|
+
// 5. Minor bump (same major) → 1.4.7 → 1.5.0
|
|
17
|
+
// 6. Minor bump with multi-digit minor → 1.10.3 → 1.11.0
|
|
18
|
+
// 7. No HEAD (git show fails) → falls back to current major, bumps minor
|
|
19
|
+
// 8. Major changed → 3.2.1 → 3.0.0
|
|
20
|
+
// 9. Exact serialisation → 2-space JSON + trailing newline, fields preserved
|
|
21
|
+
// 10. Git commands → `git show HEAD:package.json` then `git add package.json`
|
|
22
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
23
|
+
|
|
24
|
+
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
25
|
+
|
|
26
|
+
vi.mock('fs', () => ({
|
|
27
|
+
readFileSync: vi.fn(),
|
|
28
|
+
writeFileSync: vi.fn(),
|
|
29
|
+
}))
|
|
30
|
+
|
|
31
|
+
vi.mock('child_process', () => ({
|
|
32
|
+
execSync: vi.fn(),
|
|
33
|
+
}))
|
|
34
|
+
|
|
35
|
+
import { readFileSync, writeFileSync } from 'fs'
|
|
36
|
+
import { execSync } from 'child_process'
|
|
37
|
+
|
|
38
|
+
const EXIT_SENTINEL = new Error('__process_exit__')
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Re-imports bump-version.js with the current mock state.
|
|
42
|
+
*
|
|
43
|
+
* @returns {Promise<void>}
|
|
44
|
+
*/
|
|
45
|
+
async function loadModule() {
|
|
46
|
+
vi.resetModules()
|
|
47
|
+
await import('./bump-version.js')
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Wires the fs/git mocks for a normal (non-skip) run.
|
|
52
|
+
*
|
|
53
|
+
* @param {object} opts
|
|
54
|
+
* @param {object} opts.pkg - Contents returned for package.json.
|
|
55
|
+
* @param {object} [opts.prevPkg] - Contents returned for HEAD:package.json.
|
|
56
|
+
* @param {boolean} [opts.noHead] - When true, `git show` throws.
|
|
57
|
+
*/
|
|
58
|
+
function mockNormalRun({ pkg, prevPkg, noHead = false }) {
|
|
59
|
+
readFileSync.mockReturnValue(JSON.stringify(pkg))
|
|
60
|
+
execSync.mockImplementation((cmd) => {
|
|
61
|
+
if (cmd === 'git show HEAD:package.json') {
|
|
62
|
+
if (noHead) throw new Error('fatal: invalid object name HEAD')
|
|
63
|
+
return JSON.stringify(prevPkg)
|
|
64
|
+
}
|
|
65
|
+
return ''
|
|
66
|
+
})
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
describe('bump-version.js', () => {
|
|
70
|
+
let logSpy
|
|
71
|
+
let exitSpy
|
|
72
|
+
|
|
73
|
+
beforeEach(() => {
|
|
74
|
+
vi.clearAllMocks()
|
|
75
|
+
delete process.env.SKIP_BUMP
|
|
76
|
+
logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
77
|
+
exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => {})
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
afterEach(() => {
|
|
81
|
+
delete process.env.SKIP_BUMP
|
|
82
|
+
logSpy.mockRestore()
|
|
83
|
+
exitSpy.mockRestore()
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
// ── SKIP_BUMP guard ──────────────────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
it('SKIP_BUMP=1: logs skip message, exits 0, touches nothing', async () => {
|
|
89
|
+
process.env.SKIP_BUMP = '1'
|
|
90
|
+
exitSpy.mockImplementation(() => {
|
|
91
|
+
throw EXIT_SENTINEL
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
await expect(loadModule()).rejects.toBe(EXIT_SENTINEL)
|
|
95
|
+
|
|
96
|
+
expect(logSpy).toHaveBeenCalledWith('SKIP_BUMP set — version bump skipped')
|
|
97
|
+
expect(exitSpy).toHaveBeenCalledWith(0)
|
|
98
|
+
expect(readFileSync).not.toHaveBeenCalled()
|
|
99
|
+
expect(writeFileSync).not.toHaveBeenCalled()
|
|
100
|
+
expect(execSync).not.toHaveBeenCalled()
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('SKIP_BUMP=yes: any non-empty value skips the bump', async () => {
|
|
104
|
+
process.env.SKIP_BUMP = 'yes'
|
|
105
|
+
exitSpy.mockImplementation(() => {
|
|
106
|
+
throw EXIT_SENTINEL
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
await expect(loadModule()).rejects.toBe(EXIT_SENTINEL)
|
|
110
|
+
|
|
111
|
+
expect(exitSpy).toHaveBeenCalledWith(0)
|
|
112
|
+
expect(writeFileSync).not.toHaveBeenCalled()
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('SKIP_BUMP=0: the string "0" is truthy, so the bump is skipped', async () => {
|
|
116
|
+
process.env.SKIP_BUMP = '0'
|
|
117
|
+
exitSpy.mockImplementation(() => {
|
|
118
|
+
throw EXIT_SENTINEL
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
await expect(loadModule()).rejects.toBe(EXIT_SENTINEL)
|
|
122
|
+
|
|
123
|
+
expect(exitSpy).toHaveBeenCalledWith(0)
|
|
124
|
+
expect(execSync).not.toHaveBeenCalled()
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it('SKIP_BUMP="": empty string is falsy, so the bump proceeds', async () => {
|
|
128
|
+
process.env.SKIP_BUMP = ''
|
|
129
|
+
mockNormalRun({
|
|
130
|
+
pkg: { name: 'fixture', version: '1.4.7' },
|
|
131
|
+
prevPkg: { version: '1.4.0' },
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
await loadModule()
|
|
135
|
+
|
|
136
|
+
expect(exitSpy).not.toHaveBeenCalled()
|
|
137
|
+
expect(logSpy).toHaveBeenCalledWith('Version bumped: 1.4.7 → 1.5.0')
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
// ── Version arithmetic ───────────────────────────────────────────────────
|
|
141
|
+
|
|
142
|
+
it('same major: bumps the minor and resets the patch', async () => {
|
|
143
|
+
mockNormalRun({
|
|
144
|
+
pkg: { name: 'fixture', version: '1.4.7' },
|
|
145
|
+
prevPkg: { version: '1.4.0' },
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
await loadModule()
|
|
149
|
+
|
|
150
|
+
const written = JSON.parse(writeFileSync.mock.calls[0][1])
|
|
151
|
+
expect(written.version).toBe('1.5.0')
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
it('multi-digit minor: increments numerically (1.10.3 → 1.11.0)', async () => {
|
|
155
|
+
mockNormalRun({
|
|
156
|
+
pkg: { name: 'fixture', version: '1.10.3' },
|
|
157
|
+
prevPkg: { version: '1.9.9' },
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
await loadModule()
|
|
161
|
+
|
|
162
|
+
expect(logSpy).toHaveBeenCalledWith('Version bumped: 1.10.3 → 1.11.0')
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
it('no HEAD yet: catch path keeps the current major and bumps the minor', async () => {
|
|
166
|
+
mockNormalRun({
|
|
167
|
+
pkg: { name: 'fixture', version: '2.3.1' },
|
|
168
|
+
noHead: true,
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
await loadModule()
|
|
172
|
+
|
|
173
|
+
expect(logSpy).toHaveBeenCalledWith('Version bumped: 2.3.1 → 2.4.0')
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
it('major changed: normalises to N.0.0', async () => {
|
|
177
|
+
mockNormalRun({
|
|
178
|
+
pkg: { name: 'fixture', version: '3.2.1' },
|
|
179
|
+
prevPkg: { version: '2.9.9' },
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
await loadModule()
|
|
183
|
+
|
|
184
|
+
expect(logSpy).toHaveBeenCalledWith('Version bumped: 3.2.1 → 3.0.0')
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
// ── Serialisation and git side-effects ───────────────────────────────────
|
|
188
|
+
|
|
189
|
+
it('writes 2-space JSON with a trailing newline and preserves other fields', async () => {
|
|
190
|
+
const pkg = {
|
|
191
|
+
name: 'fixture',
|
|
192
|
+
version: '1.4.7',
|
|
193
|
+
scripts: { test: 'vitest' },
|
|
194
|
+
keywords: ['a', 'b'],
|
|
195
|
+
}
|
|
196
|
+
mockNormalRun({ pkg, prevPkg: { version: '1.0.0' } })
|
|
197
|
+
|
|
198
|
+
await loadModule()
|
|
199
|
+
|
|
200
|
+
const [path, contents] = writeFileSync.mock.calls[0]
|
|
201
|
+
expect(path).toMatch(/package\.json$/)
|
|
202
|
+
expect(contents.endsWith('\n')).toBe(true)
|
|
203
|
+
expect(contents).toBe(
|
|
204
|
+
JSON.stringify({ ...pkg, version: '1.5.0' }, null, 2) + '\n',
|
|
205
|
+
)
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
it('reads HEAD:package.json then stages package.json', async () => {
|
|
209
|
+
mockNormalRun({
|
|
210
|
+
pkg: { name: 'fixture', version: '0.9.2' },
|
|
211
|
+
prevPkg: { version: '0.9.0' },
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
await loadModule()
|
|
215
|
+
|
|
216
|
+
expect(execSync).toHaveBeenNthCalledWith(
|
|
217
|
+
1,
|
|
218
|
+
'git show HEAD:package.json',
|
|
219
|
+
{ encoding: 'utf8' },
|
|
220
|
+
)
|
|
221
|
+
expect(execSync).toHaveBeenNthCalledWith(2, 'git add package.json')
|
|
222
|
+
expect(execSync).toHaveBeenCalledTimes(2)
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
it('reads package.json from the package root', async () => {
|
|
226
|
+
mockNormalRun({
|
|
227
|
+
pkg: { name: 'fixture', version: '1.0.0' },
|
|
228
|
+
prevPkg: { version: '1.0.0' },
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
await loadModule()
|
|
232
|
+
|
|
233
|
+
expect(readFileSync).toHaveBeenCalledWith(
|
|
234
|
+
expect.stringMatching(/package\.json$/),
|
|
235
|
+
'utf8',
|
|
236
|
+
)
|
|
237
|
+
})
|
|
238
|
+
})
|
|
@@ -6,41 +6,67 @@
|
|
|
6
6
|
* based on environment variables and project settings.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
9
|
+
import { spawn } from 'node:child_process'
|
|
10
|
+
import { resolve } from 'node:path'
|
|
11
|
+
import { fileURLToPath } from 'node:url'
|
|
11
12
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
/**
|
|
14
|
+
* Builds the `npx @playwright/mcp@latest` argument list from the environment.
|
|
15
|
+
*
|
|
16
|
+
* @param {NodeJS.ProcessEnv} [env=process.env] - Environment to read.
|
|
17
|
+
* @returns {string[]} Arguments passed to `npx`.
|
|
18
|
+
*/
|
|
19
|
+
export function buildArgs(env = process.env) {
|
|
20
|
+
const args = ['@playwright/mcp@latest']
|
|
15
21
|
|
|
16
|
-
|
|
17
|
-
|
|
22
|
+
if (env.HEADLESS === 'true') {
|
|
23
|
+
args.push('--headless')
|
|
24
|
+
}
|
|
18
25
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
}
|
|
26
|
+
if (env.VIEWPORT) {
|
|
27
|
+
args.push('--viewport', env.VIEWPORT)
|
|
28
|
+
}
|
|
23
29
|
|
|
24
|
-
|
|
25
|
-
if (process.env.VIEWPORT) {
|
|
26
|
-
args.push('--viewport', process.env.VIEWPORT);
|
|
30
|
+
return args
|
|
27
31
|
}
|
|
28
32
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
}
|
|
33
|
+
/**
|
|
34
|
+
* Spawns the Playwright MCP server and forwards signals / exit codes.
|
|
35
|
+
*
|
|
36
|
+
* @param {object} [options]
|
|
37
|
+
* @param {object} [options.env=process.env] - Environment to read.
|
|
38
|
+
* @param {object} [options.proc=process] - Process-like emitter for signals.
|
|
39
|
+
* @param {Function} [options.exit=process.exit] - Exit function.
|
|
40
|
+
* @returns {import('node:child_process').ChildProcess} The spawned child.
|
|
41
|
+
*/
|
|
42
|
+
export function launch({
|
|
43
|
+
env = process.env,
|
|
44
|
+
proc = process,
|
|
45
|
+
exit = process.exit,
|
|
46
|
+
} = {}) {
|
|
47
|
+
const child = spawn('npx', buildArgs(env), {
|
|
48
|
+
stdio: 'inherit',
|
|
49
|
+
shell: true,
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
proc.on('SIGINT', () => {
|
|
53
|
+
child.kill('SIGINT')
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
proc.on('SIGTERM', () => {
|
|
57
|
+
child.kill('SIGTERM')
|
|
58
|
+
})
|
|
34
59
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
});
|
|
60
|
+
child.on('exit', (code) => {
|
|
61
|
+
exit(code || 0)
|
|
62
|
+
})
|
|
39
63
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
});
|
|
64
|
+
return child
|
|
65
|
+
}
|
|
43
66
|
|
|
44
|
-
|
|
45
|
-
process.
|
|
46
|
-
|
|
67
|
+
const isDirectRun =
|
|
68
|
+
process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)
|
|
69
|
+
|
|
70
|
+
if (isDirectRun) {
|
|
71
|
+
launch()
|
|
72
|
+
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
2
|
+
// playwright-mcp-launcher.test.js — Unit tests for the MCP launcher
|
|
3
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
4
|
+
//
|
|
5
|
+
// `spawn` is mocked so no MCP server is ever launched. Signal handling and
|
|
6
|
+
// exit-code propagation are exercised through injected fakes (an EventEmitter
|
|
7
|
+
// double for the process and a fake child), plus a direct-run import that
|
|
8
|
+
// verifies the CLI bootstrap branch.
|
|
9
|
+
//
|
|
10
|
+
// Covered scenarios:
|
|
11
|
+
// 1. default args (no env flags)
|
|
12
|
+
// 2. HEADLESS=true adds --headless
|
|
13
|
+
// 3. HEADLESS=false does not
|
|
14
|
+
// 4. VIEWPORT adds --viewport <size>
|
|
15
|
+
// 5. HEADLESS + VIEWPORT together
|
|
16
|
+
// 6. spawn is called as `npx <args>` with inherited stdio + shell
|
|
17
|
+
// 7. exit code 0 is forwarded
|
|
18
|
+
// 8. non-zero exit code is forwarded
|
|
19
|
+
// 9. missing exit code falls back to 0
|
|
20
|
+
// 10. SIGINT / SIGTERM are forwarded to the child
|
|
21
|
+
// 11. launch returns the child
|
|
22
|
+
// 12. direct run auto-launches; imported elsewhere it does not
|
|
23
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
24
|
+
|
|
25
|
+
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
26
|
+
import { EventEmitter } from 'node:events'
|
|
27
|
+
import { fileURLToPath } from 'node:url'
|
|
28
|
+
|
|
29
|
+
const cp = vi.hoisted(() => ({ spawn: vi.fn() }))
|
|
30
|
+
|
|
31
|
+
vi.mock('node:child_process', () => ({
|
|
32
|
+
spawn: cp.spawn,
|
|
33
|
+
}))
|
|
34
|
+
|
|
35
|
+
import { spawn } from 'node:child_process'
|
|
36
|
+
import { buildArgs, launch } from './playwright-mcp-launcher.js'
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Creates a fake ChildProcess.
|
|
40
|
+
*
|
|
41
|
+
* @returns {object} Fake child with kill/on and a helper to emit 'exit'.
|
|
42
|
+
*/
|
|
43
|
+
function makeChild() {
|
|
44
|
+
const handlers = {}
|
|
45
|
+
return {
|
|
46
|
+
kill: vi.fn(),
|
|
47
|
+
on: vi.fn((event, handler) => {
|
|
48
|
+
handlers[event] = handler
|
|
49
|
+
}),
|
|
50
|
+
emitExit(code) {
|
|
51
|
+
handlers.exit?.(code)
|
|
52
|
+
},
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
describe('playwright-mcp-launcher', () => {
|
|
57
|
+
let child
|
|
58
|
+
|
|
59
|
+
beforeEach(() => {
|
|
60
|
+
vi.clearAllMocks()
|
|
61
|
+
child = makeChild()
|
|
62
|
+
spawn.mockReturnValue(child)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
afterEach(() => {
|
|
66
|
+
vi.restoreAllMocks()
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
// ── buildArgs ────────────────────────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
it('buildArgs: returns only the package when no env flags are set', () => {
|
|
72
|
+
expect(buildArgs({})).toEqual(['@playwright/mcp@latest'])
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('buildArgs: HEADLESS=true adds --headless', () => {
|
|
76
|
+
expect(buildArgs({ HEADLESS: 'true' })).toEqual([
|
|
77
|
+
'@playwright/mcp@latest',
|
|
78
|
+
'--headless',
|
|
79
|
+
])
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('buildArgs: HEADLESS=false does not add --headless', () => {
|
|
83
|
+
expect(buildArgs({ HEADLESS: 'false' })).toEqual(['@playwright/mcp@latest'])
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('buildArgs: VIEWPORT adds a two-token viewport flag', () => {
|
|
87
|
+
expect(buildArgs({ VIEWPORT: '1280x720' })).toEqual([
|
|
88
|
+
'@playwright/mcp@latest',
|
|
89
|
+
'--viewport',
|
|
90
|
+
'1280x720',
|
|
91
|
+
])
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('buildArgs: combines headless and viewport flags', () => {
|
|
95
|
+
expect(buildArgs({ HEADLESS: 'true', VIEWPORT: '800x600' })).toEqual([
|
|
96
|
+
'@playwright/mcp@latest',
|
|
97
|
+
'--headless',
|
|
98
|
+
'--viewport',
|
|
99
|
+
'800x600',
|
|
100
|
+
])
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
// ── launch ───────────────────────────────────────────────────────────────
|
|
104
|
+
|
|
105
|
+
it('launch: spawns npx with inherited stdio and shell', () => {
|
|
106
|
+
launch({ env: {}, proc: new EventEmitter(), exit: vi.fn() })
|
|
107
|
+
|
|
108
|
+
expect(spawn).toHaveBeenCalledWith('npx', ['@playwright/mcp@latest'], {
|
|
109
|
+
stdio: 'inherit',
|
|
110
|
+
shell: true,
|
|
111
|
+
})
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('launch: returns the spawned child', () => {
|
|
115
|
+
const returned = launch({
|
|
116
|
+
env: {},
|
|
117
|
+
proc: new EventEmitter(),
|
|
118
|
+
exit: vi.fn(),
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
expect(returned).toBe(child)
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('launch: forwards exit code 0', () => {
|
|
125
|
+
const exit = vi.fn()
|
|
126
|
+
launch({ env: {}, proc: new EventEmitter(), exit })
|
|
127
|
+
|
|
128
|
+
child.emitExit(0)
|
|
129
|
+
|
|
130
|
+
expect(exit).toHaveBeenCalledWith(0)
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('launch: forwards a non-zero exit code', () => {
|
|
134
|
+
const exit = vi.fn()
|
|
135
|
+
launch({ env: {}, proc: new EventEmitter(), exit })
|
|
136
|
+
|
|
137
|
+
child.emitExit(5)
|
|
138
|
+
|
|
139
|
+
expect(exit).toHaveBeenCalledWith(5)
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it('launch: falls back to 0 when no exit code is provided', () => {
|
|
143
|
+
const exit = vi.fn()
|
|
144
|
+
launch({ env: {}, proc: new EventEmitter(), exit })
|
|
145
|
+
|
|
146
|
+
child.emitExit(undefined)
|
|
147
|
+
|
|
148
|
+
expect(exit).toHaveBeenCalledWith(0)
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it('launch: forwards SIGINT to the child', () => {
|
|
152
|
+
const proc = new EventEmitter()
|
|
153
|
+
launch({ env: {}, proc, exit: vi.fn() })
|
|
154
|
+
|
|
155
|
+
proc.emit('SIGINT')
|
|
156
|
+
|
|
157
|
+
expect(child.kill).toHaveBeenCalledWith('SIGINT')
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
it('launch: forwards SIGTERM to the child', () => {
|
|
161
|
+
const proc = new EventEmitter()
|
|
162
|
+
launch({ env: {}, proc, exit: vi.fn() })
|
|
163
|
+
|
|
164
|
+
proc.emit('SIGTERM')
|
|
165
|
+
|
|
166
|
+
expect(child.kill).toHaveBeenCalledWith('SIGTERM')
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
// ── Direct-run guard ─────────────────────────────────────────────────────
|
|
170
|
+
|
|
171
|
+
it('direct run: auto-launches when argv[1] is this module', async () => {
|
|
172
|
+
const modulePath = fileURLToPath(
|
|
173
|
+
new URL('./playwright-mcp-launcher.js', import.meta.url),
|
|
174
|
+
)
|
|
175
|
+
const originalArgv1 = process.argv[1]
|
|
176
|
+
const intListeners = process.listeners('SIGINT')
|
|
177
|
+
const termListeners = process.listeners('SIGTERM')
|
|
178
|
+
process.argv[1] = modulePath
|
|
179
|
+
|
|
180
|
+
try {
|
|
181
|
+
vi.resetModules()
|
|
182
|
+
await import('./playwright-mcp-launcher.js')
|
|
183
|
+
|
|
184
|
+
expect(spawn).toHaveBeenCalledWith(
|
|
185
|
+
'npx',
|
|
186
|
+
expect.arrayContaining(['@playwright/mcp@latest']),
|
|
187
|
+
{ stdio: 'inherit', shell: true },
|
|
188
|
+
)
|
|
189
|
+
} finally {
|
|
190
|
+
process.argv[1] = originalArgv1
|
|
191
|
+
for (const listener of process.listeners('SIGINT')) {
|
|
192
|
+
if (!intListeners.includes(listener)) {
|
|
193
|
+
process.removeListener('SIGINT', listener)
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
for (const listener of process.listeners('SIGTERM')) {
|
|
197
|
+
if (!termListeners.includes(listener)) {
|
|
198
|
+
process.removeListener('SIGTERM', listener)
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
it('imported with an empty argv[1]: does not launch', async () => {
|
|
205
|
+
const originalArgv1 = process.argv[1]
|
|
206
|
+
process.argv[1] = ''
|
|
207
|
+
|
|
208
|
+
try {
|
|
209
|
+
vi.resetModules()
|
|
210
|
+
await import('./playwright-mcp-launcher.js')
|
|
211
|
+
|
|
212
|
+
expect(spawn).not.toHaveBeenCalled()
|
|
213
|
+
} finally {
|
|
214
|
+
process.argv[1] = originalArgv1
|
|
215
|
+
}
|
|
216
|
+
})
|
|
217
|
+
})
|