openspec-playwright 0.1.62 → 0.1.63

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.
Files changed (51) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +4 -3
  3. package/README.zh-CN.md +4 -3
  4. package/bin/CLAUDE.md +11 -0
  5. package/dist/CLAUDE.md +17 -0
  6. package/dist/commands/doctor.d.ts +4 -1
  7. package/dist/commands/doctor.js +110 -73
  8. package/dist/commands/doctor.js.map +1 -1
  9. package/dist/commands/editors.js +149 -95
  10. package/dist/commands/editors.js.map +1 -1
  11. package/dist/commands/init.js +105 -97
  12. package/dist/commands/init.js.map +1 -1
  13. package/dist/commands/mcpSync.js +46 -31
  14. package/dist/commands/mcpSync.js.map +1 -1
  15. package/dist/commands/run.d.ts +13 -0
  16. package/dist/commands/run.js +74 -51
  17. package/dist/commands/run.js.map +1 -1
  18. package/dist/commands/uninstall.d.ts +1 -0
  19. package/dist/commands/uninstall.js +133 -0
  20. package/dist/commands/uninstall.js.map +1 -0
  21. package/dist/commands/update.js +79 -68
  22. package/dist/commands/update.js.map +1 -1
  23. package/dist/index.js +33 -26
  24. package/dist/index.js.map +1 -1
  25. package/package.json +19 -1
  26. package/schemas/playwright-e2e/templates/playwright.config.ts +22 -22
  27. package/templates/CLAUDE.md +15 -0
  28. package/templates/seed.spec.ts +5 -3
  29. package/.claude/commands/opsx/e2e-body.md +0 -39
  30. package/.claude/commands/opsx/e2e.md +0 -47
  31. package/.claude/skills/openspec-e2e/SKILL.md +0 -464
  32. package/.github/workflows/release.yml +0 -81
  33. package/docs/plans/2026-03-26-openspec-playwright-design.md +0 -180
  34. package/employee-standards.md +0 -44
  35. package/openspec/schemas/playwright-e2e/schema.yaml +0 -56
  36. package/openspec/schemas/playwright-e2e/templates/e2e-test.ts +0 -55
  37. package/openspec/schemas/playwright-e2e/templates/playwright.config.ts +0 -52
  38. package/openspec/schemas/playwright-e2e/templates/report.md +0 -27
  39. package/openspec/schemas/playwright-e2e/templates/test-plan.md +0 -24
  40. package/openspec-playwright-0.1.62.tgz +0 -0
  41. package/release-notes.md +0 -5
  42. package/src/commands/doctor.ts +0 -115
  43. package/src/commands/editors.ts +0 -606
  44. package/src/commands/init.ts +0 -252
  45. package/src/commands/mcpSync.ts +0 -160
  46. package/src/commands/run.ts +0 -172
  47. package/src/commands/update.ts +0 -192
  48. package/src/index.ts +0 -47
  49. package/tests/editors.test.ts +0 -180
  50. package/tsconfig.json +0 -18
  51. package/vitest.config.ts +0 -9
@@ -1,252 +0,0 @@
1
- import { execSync } from 'child_process';
2
- import {
3
- existsSync,
4
- readFileSync,
5
- writeFileSync,
6
- mkdirSync,
7
- } from 'fs';
8
- import { join } from 'path';
9
- import { homedir } from 'os';
10
- import { fileURLToPath } from 'url';
11
- import chalk from 'chalk';
12
- import { readFile } from 'fs/promises';
13
- import { syncMcpTools } from './mcpSync.js';
14
- import { detectEditors, detectCodex, installForAllEditors, installSkill, installProjectClaudeMd, readEmployeeStandards, claudeAdapter } from './editors.js';
15
-
16
- const TEMPLATE_DIR = fileURLToPath(new URL('../../templates', import.meta.url));
17
- const SCHEMA_DIR = fileURLToPath(new URL('../../schemas', import.meta.url));
18
- const SKILL_SRC = fileURLToPath(new URL('../../.claude/skills/openspec-e2e/SKILL.md', import.meta.url));
19
- const CMD_BODY_SRC = fileURLToPath(new URL('../../.claude/commands/opsx/e2e-body.md', import.meta.url));
20
- const EMPLOYEE_STANDARDS_SRC = fileURLToPath(new URL('../../employee-standards.md', import.meta.url));
21
-
22
- export interface InitOptions {
23
- change?: string;
24
- mcp?: boolean;
25
- seed?: boolean;
26
- }
27
-
28
- export async function init(options: InitOptions) {
29
- console.log(chalk.blue('\nšŸ”§ OpenSpec + Playwright E2E Setup\n'));
30
-
31
- const projectRoot = process.cwd();
32
-
33
- // 1. Check prerequisites
34
- console.log(chalk.blue('─── Prerequisites ───'));
35
-
36
- const hasNode = execCmd('node --version', 'Node.js', true);
37
- const hasNpm = execCmd('npm --version', 'npm', true);
38
- const hasOpenspec = execCmd('npx openspec --version 2>/dev/null || echo "not found"', 'OpenSpec', true);
39
-
40
- if (!hasNode || !hasNpm) {
41
- console.log(chalk.red(' āœ— Node.js/npm is required'));
42
- process.exit(1);
43
- }
44
- console.log(chalk.green(' āœ“ Node.js and npm found'));
45
-
46
- // 2. Check OpenSpec
47
- if (!existsSync(join(projectRoot, 'openspec'))) {
48
- console.log(chalk.yellow('\n⚠ OpenSpec not initialized. Run these commands first:'));
49
- console.log(chalk.gray(' npm install -g @fission-ai/openspec'));
50
- console.log(chalk.gray(' openspec init'));
51
- console.log(chalk.gray(' openspec config profile core'));
52
- console.log(chalk.gray(' openspec update\n'));
53
- console.log(chalk.gray(' Then run openspec-pw init again.\n'));
54
- return;
55
- }
56
- console.log(chalk.green(' āœ“ OpenSpec initialized'));
57
-
58
- // 3. Install Playwright MCP (global)
59
- if (options.mcp !== false) {
60
- console.log(chalk.blue('\n─── Installing Playwright MCP ───'));
61
-
62
- // Check if playwright MCP already exists in global config
63
- const claudeJsonPath = join(homedir(), '.claude.json');
64
- const claudeJson = existsSync(claudeJsonPath) ? JSON.parse(readFileSync(claudeJsonPath, 'utf-8')) : {};
65
- const globalMcp = claudeJson?.mcpServers ?? {};
66
- const localMcp = claudeJson?.projects?.[projectRoot]?.mcpServers ?? {};
67
-
68
- if (globalMcp['playwright'] || localMcp['playwright']) {
69
- console.log(chalk.green(' āœ“ Playwright MCP already installed'));
70
- } else {
71
- try {
72
- execSync('claude mcp add playwright npx @playwright/mcp@latest', {
73
- cwd: projectRoot,
74
- stdio: 'inherit',
75
- });
76
- console.log(chalk.green(' āœ“ Playwright MCP installed globally'));
77
- console.log(chalk.gray(' (Restart Claude Code to activate)'));
78
- } catch {
79
- console.log(chalk.yellow(' ⚠ Failed to run claude mcp add'));
80
- console.log(chalk.gray(' Run manually: claude mcp add playwright npx @playwright/mcp@latest'));
81
- console.log(chalk.gray(' (Restart Claude Code to activate the MCP server)'));
82
- }
83
- }
84
- }
85
-
86
- // 4. Install E2E commands for detected editors
87
- console.log(chalk.blue('\n─── Installing E2E Commands ───'));
88
- const detected = detectEditors(projectRoot);
89
- const codex = detectCodex();
90
- const adapters = codex ? [...detected, codex] : detected;
91
- if (adapters.length > 0) {
92
- const body = await readFile(CMD_BODY_SRC, 'utf-8');
93
- installForAllEditors(body, adapters, projectRoot);
94
- } else {
95
- const body = await readFile(CMD_BODY_SRC, 'utf-8');
96
- installForAllEditors(body, [claudeAdapter], projectRoot);
97
- }
98
-
99
- // Claude Code also gets the SKILL.md
100
- if (existsSync(join(projectRoot, '.claude'))) {
101
- const skillContent = await readFile(SKILL_SRC, 'utf-8');
102
- installSkill(projectRoot, skillContent);
103
- }
104
-
105
- // 5. Sync Healer tools with latest @playwright/mcp (Claude Code only)
106
- if (existsSync(join(projectRoot, '.claude'))) {
107
- console.log(chalk.blue('\n─── Syncing Healer Tools ───'));
108
- const skillDest = join(projectRoot, '.claude', 'skills', 'openspec-e2e', 'SKILL.md');
109
- await syncMcpTools(skillDest, true);
110
- } else {
111
- console.log(chalk.blue('\n─── Syncing Healer Tools ───'));
112
- console.log(chalk.gray(' - Claude Code not detected, skipping MCP sync'));
113
- }
114
-
115
- // 6. Install OpenSpec schema
116
- console.log(chalk.blue('\n─── Installing OpenSpec Schema ───'));
117
- await installSchema(projectRoot);
118
-
119
- // 7. Generate seed test
120
- if (options.seed !== false) {
121
- console.log(chalk.blue('\n─── Generating Seed Test ───'));
122
- await generateSeedTest(projectRoot);
123
- }
124
-
125
- // 8. Generate app-knowledge.md
126
- console.log(chalk.blue('\n─── Generating App Knowledge ───'));
127
- await generateAppKnowledge(projectRoot);
128
-
129
- // 9. Install employee-grade CLAUDE.md
130
- console.log(chalk.blue('\n─── Installing Employee Standards ───'));
131
- const standards = readEmployeeStandards(EMPLOYEE_STANDARDS_SRC);
132
- if (standards) {
133
- installProjectClaudeMd(projectRoot, standards);
134
- }
135
-
136
- // 10. Summary
137
- console.log(chalk.blue('\n─── Summary ───'));
138
- console.log(chalk.green(' āœ“ Setup complete!\n'));
139
-
140
- console.log(chalk.bold('Next steps:'));
141
- console.log(chalk.gray(' 1. Install Playwright browsers: npx playwright install --with-deps'));
142
- console.log(chalk.gray(' 2. Customize tests/playwright/credentials.yaml with your test user'));
143
- console.log(chalk.gray(' 3. Set credentials: export E2E_USERNAME=xxx E2E_PASSWORD=yyy'));
144
- console.log(chalk.gray(' 4. Run auth setup: npx playwright test --project=setup'));
145
- const hasClaude = existsSync(join(projectRoot, '.claude'));
146
- if (hasClaude) {
147
- console.log(chalk.gray(' 5. In Claude Code, run: /opsx:e2e <change-name>'));
148
- }
149
- console.log(chalk.gray(` ${hasClaude ? '6.' : '5.'} Or: openspec-pw run <change-name>`));
150
- console.log(chalk.gray(` ${hasClaude ? '7.' : '6.'} Or: openspec-pw doctor to verify setup\n`));
151
-
152
- console.log(chalk.bold('How it works:'));
153
- console.log(chalk.gray(' /opsx:e2e reads your OpenSpec specs and runs Playwright'));
154
- console.log(chalk.gray(' E2E tests through a three-agent pipeline:'));
155
- console.log(chalk.gray(' Planner → Generator → Healer\n'));
156
- }
157
-
158
- async function generateSeedTest(projectRoot: string) {
159
- const testsDir = join(projectRoot, 'tests', 'playwright');
160
- mkdirSync(testsDir, { recursive: true });
161
-
162
- const seedPath = join(testsDir, 'seed.spec.ts');
163
- if (existsSync(seedPath)) {
164
- console.log(chalk.gray(' - seed.spec.ts already exists, skipping'));
165
- } else {
166
- const seedContent = await readFile(TEMPLATE_DIR + '/seed.spec.ts', 'utf-8');
167
- writeFileSync(seedPath, seedContent);
168
- console.log(chalk.green(' āœ“ Generated: tests/playwright/seed.spec.ts'));
169
- }
170
-
171
- // Generate auth.setup.ts
172
- const authSetupPath = join(testsDir, 'auth.setup.ts');
173
- if (existsSync(authSetupPath)) {
174
- console.log(chalk.gray(' - auth.setup.ts already exists, skipping'));
175
- } else {
176
- const authContent = await readFile(TEMPLATE_DIR + '/auth.setup.ts', 'utf-8');
177
- writeFileSync(authSetupPath, authContent);
178
- console.log(chalk.green(' āœ“ Generated: tests/playwright/auth.setup.ts'));
179
- }
180
-
181
- // Generate credentials.yaml
182
- const credsPath = join(testsDir, 'credentials.yaml');
183
- if (existsSync(credsPath)) {
184
- console.log(chalk.gray(' - credentials.yaml already exists, skipping'));
185
- } else {
186
- const credsContent = await readFile(TEMPLATE_DIR + '/credentials.yaml', 'utf-8');
187
- writeFileSync(credsPath, credsContent);
188
- console.log(chalk.green(' āœ“ Generated: tests/playwright/credentials.yaml'));
189
- }
190
-
191
- console.log(chalk.gray(' (Customize BASE_URL and credentials for your app)'));
192
- }
193
-
194
- async function generateAppKnowledge(projectRoot: string) {
195
- const src = join(SCHEMA_DIR, 'playwright-e2e', 'templates', 'app-knowledge.md');
196
- const dest = join(projectRoot, 'tests', 'playwright', 'app-knowledge.md');
197
-
198
- if (existsSync(dest)) {
199
- console.log(chalk.gray(' - app-knowledge.md already exists, skipping'));
200
- return;
201
- }
202
-
203
- if (existsSync(src)) {
204
- writeFileSync(dest, readFileSync(src));
205
- console.log(chalk.green(' āœ“ Generated: tests/playwright/app-knowledge.md'));
206
- }
207
- }
208
-
209
- async function installSchema(projectRoot: string) {
210
- const schemaSrc = SCHEMA_DIR + '/playwright-e2e';
211
- const schemaDest = join(projectRoot, 'openspec', 'schemas', 'playwright-e2e');
212
- const schemaFiles = ['schema.yaml'];
213
-
214
- mkdirSync(schemaDest, { recursive: true });
215
- for (const file of schemaFiles) {
216
- const src = join(schemaSrc, file);
217
- const dest = join(schemaDest, file);
218
- if (existsSync(src)) {
219
- writeFileSync(dest, readFileSync(src));
220
- }
221
- }
222
-
223
- // Copy templates
224
- const templatesSrc = join(schemaSrc, 'templates');
225
- const templatesDest = join(schemaDest, 'templates');
226
- mkdirSync(templatesDest, { recursive: true });
227
- const templateFiles = ['test-plan.md', 'report.md', 'e2e-test.ts', 'playwright.config.ts', 'app-knowledge.md'];
228
- for (const file of templateFiles) {
229
- const src = join(templatesSrc, file);
230
- const dest = join(templatesDest, file);
231
- if (existsSync(src)) {
232
- writeFileSync(dest, readFileSync(src));
233
- }
234
- }
235
-
236
- console.log(chalk.green(' āœ“ Schema installed: openspec/schemas/playwright-e2e/'));
237
- }
238
-
239
- function execCmd(
240
- cmd: string,
241
- name: string,
242
- silent = false
243
- ): boolean {
244
- try {
245
- execSync(cmd, { stdio: 'pipe' });
246
- if (!silent) console.log(chalk.green(` āœ“ ${name} found`));
247
- return true;
248
- } catch {
249
- if (!silent) console.log(chalk.yellow(` ⚠ ${name} not found`));
250
- return false;
251
- }
252
- }
@@ -1,160 +0,0 @@
1
- import { exec } from 'child_process';
2
- import { existsSync, readdirSync, readFileSync, writeFileSync, mkdirSync, rmSync } from 'fs';
3
- import { join } from 'path';
4
- import { tmpdir } from 'os';
5
- import { promisify } from 'util';
6
- import chalk from 'chalk';
7
- import * as tar from 'tar';
8
-
9
- export const MCP_VERSION_MARKER = '<!-- MCP_VERSION:';
10
-
11
- export const DEFAULT_HEALER_TOOLS = [
12
- { name: 'browser_navigate', purpose: "Go to the failing test's page" },
13
- { name: 'browser_snapshot', purpose: 'Get page structure to find equivalent selectors' },
14
- { name: 'browser_console_messages', purpose: 'Diagnose JS errors that may cause failures' },
15
- { name: 'browser_take_screenshot', purpose: 'Visually compare before/after fixes' },
16
- { name: 'browser_run_code', purpose: 'Execute custom fix logic (optional)' },
17
- ];
18
-
19
- /** Extract MCP version from SKILL.md marker */
20
- export function getStoredMcpVersion(skillContent: string): string | null {
21
- const idx = skillContent.indexOf(MCP_VERSION_MARKER);
22
- if (idx === -1) return null;
23
- const end = skillContent.indexOf(' -->', idx);
24
- return skillContent.slice(idx + MCP_VERSION_MARKER.length, end).trim();
25
- }
26
-
27
- /** Remove all existing MCP_VERSION comment lines from content */
28
- function removeMcpVersionMarkers(content: string): string {
29
- return content
30
- .split('\n')
31
- .filter(line => !line.trim().startsWith(MCP_VERSION_MARKER))
32
- .join('\n');
33
- }
34
-
35
- /** Build the Healer tools table markdown */
36
- function buildHealerTable(version: string, tools: Array<{ name: string; purpose: string }>): string {
37
- const rows = tools.map(t => `| \`${t.name}\` | ${t.purpose} |`).join('\n');
38
- return `${MCP_VERSION_MARKER} ${version} -->\n\n| Tool | Purpose |\n|------|---------|\n${rows}`;
39
- }
40
-
41
- /** Replace the Healer tools table in SKILL.md */
42
- export function updateHealerTable(
43
- skillContent: string,
44
- version: string,
45
- tools: Array<{ name: string; purpose: string }>
46
- ): string {
47
- const noMarkers = removeMcpVersionMarkers(skillContent);
48
- const start = noMarkers.indexOf('| Tool | Purpose |');
49
- if (start === -1) return skillContent;
50
- let end = noMarkers.indexOf('\n\n', start);
51
- if (end === -1) end = noMarkers.length;
52
-
53
- const before = noMarkers.slice(0, start);
54
- const after = noMarkers.slice(end);
55
- return before + buildHealerTable(version, tools) + after;
56
- }
57
-
58
- /** Fetch latest @playwright/mcp version from npm registry */
59
- export function getLatestMcpVersion(): Promise<string | null> {
60
- return new Promise((resolve) => {
61
- exec('npm show @playwright/mcp version --json', { timeout: 15000 }, (err, stdout) => {
62
- if (err) { resolve(null); return; }
63
- try { resolve(JSON.parse(stdout.trim())); } catch { resolve(null); }
64
- });
65
- });
66
- }
67
-
68
- const execAsync = promisify(exec);
69
-
70
- /** Extract a .tgz tarball to a destination directory (cross-platform) */
71
- async function extractTarball(tarballPath: string, destDir: string): Promise<void> {
72
- rmSync(destDir, { recursive: true, force: true });
73
- mkdirSync(destDir, { recursive: true });
74
- await tar.extract({ file: tarballPath, cwd: destDir, strip: 1 });
75
- }
76
-
77
- /** Parse README markdown to extract browser_* tool entries */
78
- function parseMcpReadme(content: string): Array<{ name: string; purpose: string }> {
79
- const tools: Array<{ name: string; purpose: string }> = [];
80
- const re = /-\s+\*\*`?([^`*\n]+)`?\*\*\s*-\s*Title:\s*([^\n]+)/g;
81
- let m;
82
- while ((m = re.exec(content)) !== null) {
83
- const name = m[1].trim();
84
- if (name.startsWith('browser_')) {
85
- const purpose = m[2].trim().replace(/\.$/, '');
86
- tools.push({ name, purpose });
87
- }
88
- }
89
- return tools;
90
- }
91
-
92
- /**
93
- * Fetch @playwright/mcp tools from npm package.
94
- * Downloads the tarball, extracts README, parses tool names.
95
- */
96
- export async function fetchMcpTools(version: string): Promise<Array<{ name: string; purpose: string }>> {
97
- const tmpDir = join(tmpdir(), `openspec-pw-mcp-${version}`);
98
- try {
99
- await execAsync(
100
- `npm pack @playwright/mcp@${version} --pack-destination ${tmpDir}`,
101
- { timeout: 30000 }
102
- );
103
- const tgzFiles = readdirSync(tmpDir).filter(f => f.startsWith('playwright-mcp-') && f.endsWith('.tgz'));
104
- if (tgzFiles.length === 0) return [];
105
- const tarballPath = join(tmpDir, tgzFiles[0]);
106
- const extractDir = join(tmpDir, 'pkg');
107
- await extractTarball(tarballPath, extractDir);
108
- const readmePath = join(extractDir, 'README.md');
109
- const content = existsSync(readmePath) ? readFileSync(readmePath, 'utf-8') : '';
110
- rmSync(tmpDir, { recursive: true, force: true });
111
- return parseMcpReadme(content);
112
- } catch {
113
- return [];
114
- }
115
- }
116
-
117
- /**
118
- * Sync Healer tools table in SKILL.md with latest @playwright/mcp.
119
- * Returns true if updated, false if already current or failed.
120
- */
121
- export async function syncMcpTools(
122
- skillDest: string,
123
- verbose = false
124
- ): Promise<boolean> {
125
- const latestVersion = await getLatestMcpVersion();
126
- if (!latestVersion) {
127
- if (verbose) console.log(chalk.yellow(' ⚠ Could not fetch latest @playwright/mcp version'));
128
- return false;
129
- }
130
-
131
- if (!existsSync(skillDest)) {
132
- if (verbose) console.log(chalk.gray(' - SKILL.md not found, skipping MCP sync'));
133
- return false;
134
- }
135
-
136
- const skillContent = readFileSync(skillDest, 'utf-8');
137
- const storedVersion = getStoredMcpVersion(skillContent);
138
-
139
- if (storedVersion === latestVersion) {
140
- if (verbose) console.log(chalk.gray(` - Healer tools current (${latestVersion})`));
141
- return false;
142
- }
143
-
144
- if (verbose) console.log(chalk.blue(` - Updating from ${storedVersion ?? 'unknown'} → ${latestVersion}`));
145
-
146
- const tools = await fetchMcpTools(latestVersion);
147
- const toolSet = tools.length > 0 ? tools : DEFAULT_HEALER_TOOLS;
148
-
149
- const updated = updateHealerTable(skillContent, latestVersion, toolSet);
150
- writeFileSync(skillDest, updated);
151
-
152
- if (verbose) {
153
- if (tools.length > 0) {
154
- console.log(chalk.green(` āœ“ Healer tools synced to ${latestVersion} (${tools.length} tools)`));
155
- } else {
156
- console.log(chalk.green(` āœ“ Healer tools synced to ${latestVersion} (default set)`));
157
- }
158
- }
159
- return true;
160
- }
@@ -1,172 +0,0 @@
1
- import { execSync } from 'child_process';
2
- import { existsSync, mkdirSync, writeFileSync } from 'fs';
3
- import { join } from 'path';
4
- import chalk from 'chalk';
5
-
6
- export interface RunOptions {
7
- project?: string;
8
- timeout?: number;
9
- }
10
-
11
- const REPORTS_DIR = 'openspec/reports';
12
-
13
- export async function run(changeName: string, options: RunOptions) {
14
- console.log(chalk.blue(`\nšŸ” OpenSpec Playwright E2E: ${changeName}\n`));
15
-
16
- const projectRoot = process.cwd();
17
-
18
- // 1. Verify test file exists
19
- const testFile = join(projectRoot, 'tests', 'playwright', `${changeName}.spec.ts`);
20
- if (!existsSync(testFile)) {
21
- console.log(chalk.red(` āœ— Test file not found: tests/playwright/${changeName}.spec.ts`));
22
- console.log(chalk.gray(' Run /opsx:e2e first to generate tests.\n'));
23
- process.exit(1);
24
- }
25
-
26
- // 2. Setup reports dir
27
- mkdirSync(join(projectRoot, REPORTS_DIR), { recursive: true });
28
-
29
- // 3. Detect auth credentials
30
- const credsPath = join(projectRoot, 'tests', 'playwright', 'credentials.yaml');
31
- const hasCredentials = existsSync(credsPath);
32
- if (!hasCredentials) {
33
- console.log(chalk.yellow(' ⚠ No credentials.yaml found — tests may fail if auth required'));
34
- }
35
-
36
- // 4. Run Playwright tests with output capture
37
- console.log(chalk.blue('─── Running Tests ───'));
38
-
39
- const args = ['npx', 'playwright', 'test', testFile, '--reporter=list'];
40
- if (options.project) {
41
- args.push('--project=' + options.project);
42
- }
43
-
44
- let testOutput = '';
45
- let exitCode = 0;
46
-
47
- try {
48
- // Capture stdout to detect port mismatch
49
- const result = execSync(args.join(' '), {
50
- cwd: projectRoot,
51
- encoding: 'utf-8',
52
- stdio: ['pipe', 'pipe', 'pipe'],
53
- timeout: (options.timeout ?? 300) * 1000,
54
- });
55
- testOutput = result;
56
- } catch (err: unknown) {
57
- exitCode = 1;
58
- const error = err as { stdout?: string; stderr?: string; status?: number };
59
- testOutput = (error.stdout ?? '') + (error.stderr ?? '');
60
- }
61
-
62
- // 5. Parse results from Playwright output
63
- const results = parsePlaywrightOutput(testOutput);
64
-
65
- // 6. Detect port mismatch
66
- if (testOutput.includes('net::ERR_CONNECTION_REFUSED') ||
67
- testOutput.includes('listen EADDRINUSE') ||
68
- testOutput.includes('0.0.0.0:')) {
69
- console.log(chalk.yellow(' ⚠ Port mismatch detected. Check BASE_URL and webServer port.'));
70
- }
71
-
72
- // 7. Generate markdown report
73
- const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
74
- const reportPath = join(projectRoot, REPORTS_DIR, `playwright-e2e-${changeName}-${timestamp}.md`);
75
-
76
- const reportContent = generateReport(changeName, timestamp, results);
77
- writeFileSync(reportPath, reportContent);
78
-
79
- // 8. Summary
80
- console.log(chalk.blue('\n─── Results ───'));
81
- console.log(` Tests: ${results.total} ` +
82
- chalk.green(`āœ“ ${results.passed}`) + ' ' +
83
- (results.failed > 0 ? chalk.red(`āœ— ${results.failed}`) : chalk.gray(`āœ— ${results.failed}`)) +
84
- ` Duration: ${results.duration}`);
85
-
86
- console.log(chalk.blue('\n─── Report ───'));
87
- console.log(chalk.green(` āœ“ ${reportPath}\n`));
88
-
89
- if (results.failed > 0) {
90
- console.log(chalk.red(`āœ— E2E verification FAILED (${results.failed} tests)`));
91
- process.exit(1);
92
- } else {
93
- console.log(chalk.green('āœ“ E2E verification PASSED'));
94
- }
95
- }
96
-
97
- interface TestResults {
98
- total: number;
99
- passed: number;
100
- failed: number;
101
- duration: string;
102
- tests: Array<{ name: string; status: 'passed' | 'failed' }>;
103
- }
104
-
105
- function parsePlaywrightOutput(output: string): TestResults {
106
- const results: TestResults = { total: 0, passed: 0, failed: 0, duration: '0s', tests: [] };
107
-
108
- // Parse: "āœ“ my-test (1.2s)" or "āœ— my-test (0.5s)"
109
- const testLineRegex = /([āœ“āœ—x]) (.+?) \((\d+(?:\.\d+)?[a-z]+)\)/g;
110
- let match;
111
- while ((match = testLineRegex.exec(output)) !== null) {
112
- const status = match[1] === 'āœ“' ? 'passed' : 'failed';
113
- results.tests.push({ name: match[2], status });
114
- results.total++;
115
- if (status === 'passed') results.passed++;
116
- else results.failed++;
117
- }
118
-
119
- // Parse duration: "N tests ran (1m 30s)" or "1 test ran (5s)"
120
- const durationMatch = output.match(/\d+ tests? ran \((\d+(?:m\s*\d+)?s?)\)/);
121
- if (durationMatch) results.duration = durationMatch[1];
122
-
123
- return results;
124
- }
125
-
126
- function generateReport(changeName: string, timestamp: string, results: TestResults): string {
127
- const lines: string[] = [
128
- `# E2E Verify Report: ${changeName}`,
129
- '',
130
- `**Change**: \`${changeName}\``,
131
- `**Generated**: ${timestamp.replace('T', ' ').slice(0, 16)} UTC`,
132
- '',
133
- '## Summary',
134
- '',
135
- '| Check | Status |',
136
- '|-------|--------|',
137
- `| Tests Run | ${results.total} |`,
138
- `| Passed | ${results.passed} |`,
139
- `| Failed | ${results.failed} |`,
140
- `| Duration | ${results.duration} |`,
141
- `| Final Status | ${results.failed === 0 ? 'āœ… PASS' : 'āŒ FAIL'} |`,
142
- '',
143
- '## Test Results',
144
- '',
145
- ];
146
-
147
- if (results.tests.length === 0) {
148
- lines.push('_(No test output captured — check Playwright configuration)_', '');
149
- } else {
150
- for (const test of results.tests) {
151
- const icon = test.status === 'passed' ? 'āœ…' : 'āŒ';
152
- lines.push(`- ${test.name}: ${icon} ${test.status}`);
153
- }
154
- lines.push('');
155
- }
156
-
157
- lines.push('## Recommendations', '');
158
- if (results.failed > 0) {
159
- lines.push(
160
- 'Review failed tests above. Common fixes:',
161
- '- Update selectors if UI changed (use `data-testid` attributes)',
162
- '- Adjust BASE_URL in seed.spec.ts if port differs',
163
- '- Set E2E_USERNAME/E2E_PASSWORD if auth is required',
164
- '- Check `npx playwright show-report` for screenshots',
165
- ''
166
- );
167
- } else {
168
- lines.push('All tests passed. No action needed.', '');
169
- }
170
-
171
- return lines.join('\n');
172
- }