openspec-playwright 0.1.16
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/.claude/commands/opsx/e2e.md +8 -0
- package/.claude/skills/openspec-e2e/SKILL.md +138 -0
- package/.github/workflows/release.yml +41 -0
- package/README.md +133 -0
- package/README.zh-CN.md +118 -0
- package/bin/openspec-pw +4 -0
- package/bin/openspec-pw.js +2 -0
- package/dist/commands/doctor.d.ts +1 -0
- package/dist/commands/doctor.js +110 -0
- package/dist/commands/doctor.js.map +1 -0
- package/dist/commands/init.d.ts +6 -0
- package/dist/commands/init.js +174 -0
- package/dist/commands/init.js.map +1 -0
- package/dist/commands/run.d.ts +5 -0
- package/dist/commands/run.js +135 -0
- package/dist/commands/run.js.map +1 -0
- package/dist/commands/update.d.ts +5 -0
- package/dist/commands/update.js +91 -0
- package/dist/commands/update.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +40 -0
- package/dist/index.js.map +1 -0
- package/docs/plans/2026-03-26-openspec-playwright-design.md +180 -0
- package/package.json +39 -0
- package/schemas/playwright-e2e/schema.yaml +56 -0
- package/schemas/playwright-e2e/templates/e2e-test.ts +55 -0
- package/schemas/playwright-e2e/templates/playwright.config.ts +52 -0
- package/schemas/playwright-e2e/templates/report.md +27 -0
- package/schemas/playwright-e2e/templates/test-plan.md +24 -0
- package/src/commands/doctor.ts +114 -0
- package/src/commands/init.ts +209 -0
- package/src/commands/run.ts +172 -0
- package/src/commands/update.ts +130 -0
- package/src/index.ts +47 -0
- package/templates/auth.setup.ts +77 -0
- package/templates/credentials.yaml +33 -0
- package/templates/seed.spec.ts +63 -0
- package/tsconfig.json +18 -0
|
@@ -0,0 +1,209 @@
|
|
|
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 chalk from 'chalk';
|
|
10
|
+
import { readFile } from 'fs/promises';
|
|
11
|
+
|
|
12
|
+
const TEMPLATE_DIR = new URL('../../templates', import.meta.url).pathname;
|
|
13
|
+
const SCHEMA_DIR = new URL('../../schemas', import.meta.url).pathname;
|
|
14
|
+
const SKILL_SRC = new URL('../../.claude/skills/openspec-e2e', import.meta.url).pathname;
|
|
15
|
+
const CMD_SRC = new URL('../../.claude/commands/opsx/e2e.md', import.meta.url).pathname;
|
|
16
|
+
|
|
17
|
+
export interface InitOptions {
|
|
18
|
+
change?: string;
|
|
19
|
+
mcp?: boolean;
|
|
20
|
+
seed?: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function init(options: InitOptions) {
|
|
24
|
+
console.log(chalk.blue('\nš§ OpenSpec + Playwright E2E Setup\n'));
|
|
25
|
+
|
|
26
|
+
const projectRoot = process.cwd();
|
|
27
|
+
|
|
28
|
+
// 1. Check prerequisites
|
|
29
|
+
console.log(chalk.blue('āāā Prerequisites āāā'));
|
|
30
|
+
|
|
31
|
+
const hasNode = execCmd('node --version', 'Node.js', true);
|
|
32
|
+
const hasNpm = execCmd('npm --version', 'npm', true);
|
|
33
|
+
const hasOpenspec = execCmd('npx openspec --version 2>/dev/null || echo "not found"', 'OpenSpec', true);
|
|
34
|
+
|
|
35
|
+
if (!hasNode || !hasNpm) {
|
|
36
|
+
console.log(chalk.red(' ā Node.js/npm is required'));
|
|
37
|
+
process.exit(1);
|
|
38
|
+
}
|
|
39
|
+
console.log(chalk.green(' ā Node.js and npm found'));
|
|
40
|
+
|
|
41
|
+
// 2. Check OpenSpec
|
|
42
|
+
if (!existsSync(join(projectRoot, 'openspec'))) {
|
|
43
|
+
console.log(chalk.yellow('\nā OpenSpec not initialized. Run these commands first:'));
|
|
44
|
+
console.log(chalk.gray(' npm install -g @fission-ai/openspec'));
|
|
45
|
+
console.log(chalk.gray(' openspec init'));
|
|
46
|
+
console.log(chalk.gray(' openspec config profile core'));
|
|
47
|
+
console.log(chalk.gray(' openspec update\n'));
|
|
48
|
+
console.log(chalk.gray(' Then run openspec-pw init again.\n'));
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
console.log(chalk.green(' ā OpenSpec initialized'));
|
|
52
|
+
|
|
53
|
+
// 3. Install Playwright MCP (global)
|
|
54
|
+
if (options.mcp !== false) {
|
|
55
|
+
console.log(chalk.blue('\nāāā Installing Playwright MCP āāā'));
|
|
56
|
+
|
|
57
|
+
// Check if playwright MCP already exists in global config
|
|
58
|
+
const claudeJsonPath = join(process.env.HOME ?? '', '.claude.json');
|
|
59
|
+
const claudeJson = existsSync(claudeJsonPath) ? JSON.parse(readFileSync(claudeJsonPath, 'utf-8')) : {};
|
|
60
|
+
const globalMcp = claudeJson?.mcpServers ?? {};
|
|
61
|
+
const localMcp = claudeJson?.projects?.[projectRoot]?.mcpServers ?? {};
|
|
62
|
+
|
|
63
|
+
if (globalMcp['playwright'] || localMcp['playwright']) {
|
|
64
|
+
console.log(chalk.green(' ā Playwright MCP already installed'));
|
|
65
|
+
} else {
|
|
66
|
+
try {
|
|
67
|
+
execSync('claude mcp add playwright npx @playwright/mcp@latest', {
|
|
68
|
+
cwd: projectRoot,
|
|
69
|
+
stdio: 'inherit',
|
|
70
|
+
});
|
|
71
|
+
console.log(chalk.green(' ā Playwright MCP installed globally'));
|
|
72
|
+
console.log(chalk.gray(' (Restart Claude Code to activate)'));
|
|
73
|
+
} catch {
|
|
74
|
+
console.log(chalk.yellow(' ā Failed to run claude mcp add'));
|
|
75
|
+
console.log(chalk.gray(' Run manually: claude mcp add playwright npx @playwright/mcp@latest'));
|
|
76
|
+
console.log(chalk.gray(' (Restart Claude Code to activate the MCP server)'));
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// 4. Copy skill files
|
|
82
|
+
console.log(chalk.blue('\nāāā Installing Claude Code Skill āāā'));
|
|
83
|
+
await installSkill(projectRoot);
|
|
84
|
+
|
|
85
|
+
// 5. Install OpenSpec schema
|
|
86
|
+
console.log(chalk.blue('\nāāā Installing OpenSpec Schema āāā'));
|
|
87
|
+
await installSchema(projectRoot);
|
|
88
|
+
|
|
89
|
+
// 6. Generate seed test
|
|
90
|
+
if (options.seed !== false) {
|
|
91
|
+
console.log(chalk.blue('\nāāā Generating Seed Test āāā'));
|
|
92
|
+
await generateSeedTest(projectRoot);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// 7. Summary
|
|
96
|
+
console.log(chalk.blue('\nāāā Summary āāā'));
|
|
97
|
+
console.log(chalk.green(' ā Setup complete!\n'));
|
|
98
|
+
|
|
99
|
+
console.log(chalk.bold('Next steps:'));
|
|
100
|
+
console.log(chalk.gray(' 1. Install Playwright browsers: npx playwright install --with-deps'));
|
|
101
|
+
console.log(chalk.gray(' 2. Customize tests/playwright/credentials.yaml with your test user'));
|
|
102
|
+
console.log(chalk.gray(' 3. Set credentials: export E2E_USERNAME=xxx E2E_PASSWORD=yyy'));
|
|
103
|
+
console.log(chalk.gray(' 4. Run auth setup: npx playwright test --project=setup'));
|
|
104
|
+
console.log(chalk.gray(' 5. In Claude Code, run: /opsx:e2e <change-name>'));
|
|
105
|
+
console.log(chalk.gray(' 6. Or: openspec-pw doctor to verify setup\n'));
|
|
106
|
+
|
|
107
|
+
console.log(chalk.bold('How it works:'));
|
|
108
|
+
console.log(chalk.gray(' /opsx:e2e reads your OpenSpec specs and runs Playwright'));
|
|
109
|
+
console.log(chalk.gray(' E2E tests through a three-agent pipeline:'));
|
|
110
|
+
console.log(chalk.gray(' Planner ā Generator ā Healer\n'));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function installSkill(projectRoot: string) {
|
|
114
|
+
const skillsDir = join(projectRoot, '.claude', 'skills');
|
|
115
|
+
const skillDir = join(skillsDir, 'openspec-e2e');
|
|
116
|
+
const cmdDir = join(projectRoot, '.claude', 'commands');
|
|
117
|
+
|
|
118
|
+
// Copy skill
|
|
119
|
+
mkdirSync(skillDir, { recursive: true });
|
|
120
|
+
const skillContent = await readFile(SKILL_SRC + '/SKILL.md', 'utf-8');
|
|
121
|
+
writeFileSync(join(skillDir, 'SKILL.md'), skillContent);
|
|
122
|
+
console.log(chalk.green(` ā Skill installed: /openspec-e2e`));
|
|
123
|
+
|
|
124
|
+
// Copy command
|
|
125
|
+
mkdirSync(join(cmdDir, 'opsx'), { recursive: true });
|
|
126
|
+
const cmdContent = await readFile(CMD_SRC, 'utf-8');
|
|
127
|
+
writeFileSync(join(cmdDir, 'opsx', 'e2e.md'), cmdContent);
|
|
128
|
+
console.log(chalk.green(` ā Command installed: /opsx:e2e`));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function generateSeedTest(projectRoot: string) {
|
|
132
|
+
const testsDir = join(projectRoot, 'tests', 'playwright');
|
|
133
|
+
mkdirSync(testsDir, { recursive: true });
|
|
134
|
+
|
|
135
|
+
const seedPath = join(testsDir, 'seed.spec.ts');
|
|
136
|
+
if (existsSync(seedPath)) {
|
|
137
|
+
console.log(chalk.gray(' - seed.spec.ts already exists, skipping'));
|
|
138
|
+
} else {
|
|
139
|
+
const seedContent = await readFile(TEMPLATE_DIR + '/seed.spec.ts', 'utf-8');
|
|
140
|
+
writeFileSync(seedPath, seedContent);
|
|
141
|
+
console.log(chalk.green(' ā Generated: tests/playwright/seed.spec.ts'));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Generate auth.setup.ts
|
|
145
|
+
const authSetupPath = join(testsDir, 'auth.setup.ts');
|
|
146
|
+
if (existsSync(authSetupPath)) {
|
|
147
|
+
console.log(chalk.gray(' - auth.setup.ts already exists, skipping'));
|
|
148
|
+
} else {
|
|
149
|
+
const authContent = await readFile(TEMPLATE_DIR + '/auth.setup.ts', 'utf-8');
|
|
150
|
+
writeFileSync(authSetupPath, authContent);
|
|
151
|
+
console.log(chalk.green(' ā Generated: tests/playwright/auth.setup.ts'));
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Generate credentials.yaml
|
|
155
|
+
const credsPath = join(testsDir, 'credentials.yaml');
|
|
156
|
+
if (existsSync(credsPath)) {
|
|
157
|
+
console.log(chalk.gray(' - credentials.yaml already exists, skipping'));
|
|
158
|
+
} else {
|
|
159
|
+
const credsContent = await readFile(TEMPLATE_DIR + '/credentials.yaml', 'utf-8');
|
|
160
|
+
writeFileSync(credsPath, credsContent);
|
|
161
|
+
console.log(chalk.green(' ā Generated: tests/playwright/credentials.yaml'));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
console.log(chalk.gray(' (Customize BASE_URL and credentials for your app)'));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function installSchema(projectRoot: string) {
|
|
168
|
+
const schemaSrc = SCHEMA_DIR + '/playwright-e2e';
|
|
169
|
+
const schemaDest = join(projectRoot, 'openspec', 'schemas', 'playwright-e2e');
|
|
170
|
+
const schemaFiles = ['schema.yaml'];
|
|
171
|
+
|
|
172
|
+
for (const file of schemaFiles) {
|
|
173
|
+
const src = join(schemaSrc, file);
|
|
174
|
+
const dest = join(schemaDest, file);
|
|
175
|
+
if (existsSync(src)) {
|
|
176
|
+
writeFileSync(dest, readFileSync(src));
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Copy templates
|
|
181
|
+
const templatesSrc = join(schemaSrc, 'templates');
|
|
182
|
+
const templatesDest = join(schemaDest, 'templates');
|
|
183
|
+
mkdirSync(templatesDest, { recursive: true });
|
|
184
|
+
const templateFiles = ['test-plan.md', 'report.md', 'e2e-test.ts', 'playwright.config.ts'];
|
|
185
|
+
for (const file of templateFiles) {
|
|
186
|
+
const src = join(templatesSrc, file);
|
|
187
|
+
const dest = join(templatesDest, file);
|
|
188
|
+
if (existsSync(src)) {
|
|
189
|
+
writeFileSync(dest, readFileSync(src));
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
console.log(chalk.green(' ā Schema installed: openspec/schemas/playwright-e2e/'));
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function execCmd(
|
|
197
|
+
cmd: string,
|
|
198
|
+
name: string,
|
|
199
|
+
silent = false
|
|
200
|
+
): boolean {
|
|
201
|
+
try {
|
|
202
|
+
execSync(cmd, { stdio: 'pipe' });
|
|
203
|
+
if (!silent) console.log(chalk.green(` ā ${name} found`));
|
|
204
|
+
return true;
|
|
205
|
+
} catch {
|
|
206
|
+
if (!silent) console.log(chalk.yellow(` ā ${name} not found`));
|
|
207
|
+
return false;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
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 chalk from 'chalk';
|
|
10
|
+
|
|
11
|
+
const SKILL_SRC = new URL('../../.claude/skills/openspec-e2e', import.meta.url).pathname;
|
|
12
|
+
const CMD_SRC = new URL('../../.claude/commands/opsx/e2e.md', import.meta.url).pathname;
|
|
13
|
+
const SCHEMA_DIR = new URL('../../schemas', import.meta.url).pathname;
|
|
14
|
+
|
|
15
|
+
export interface UpdateOptions {
|
|
16
|
+
cli?: boolean;
|
|
17
|
+
skill?: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function update(options: UpdateOptions) {
|
|
21
|
+
console.log(chalk.blue('\nš Updating OpenSpec + Playwright E2E\n'));
|
|
22
|
+
|
|
23
|
+
const projectRoot = process.cwd();
|
|
24
|
+
|
|
25
|
+
// 1. Update CLI tool (from git latest, not npm)
|
|
26
|
+
if (options.cli !== false) {
|
|
27
|
+
console.log(chalk.blue('āāā Updating CLI āāā'));
|
|
28
|
+
try {
|
|
29
|
+
execSync(
|
|
30
|
+
'npm install -g https://github.com/wxhou/openspec-playwright/archive/refs/heads/main.tar.gz',
|
|
31
|
+
{ stdio: 'inherit' }
|
|
32
|
+
);
|
|
33
|
+
console.log(chalk.green(' ā CLI updated to latest commit'));
|
|
34
|
+
} catch {
|
|
35
|
+
console.log(chalk.yellow(' ā Failed to update CLI'));
|
|
36
|
+
console.log(
|
|
37
|
+
chalk.gray(' Run manually: npm install -g https://github.com/wxhou/openspec-playwright/archive/refs/heads/main.tar.gz')
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// 2. Update skill and command from git tarball (latest commit, not npm package)
|
|
43
|
+
if (options.skill !== false) {
|
|
44
|
+
console.log(chalk.blue('\nāāā Updating Skill & Command āāā'));
|
|
45
|
+
try {
|
|
46
|
+
// Download tarball to temp file and extract
|
|
47
|
+
const tmpSkill = '/tmp/openspec-e2e-skill.tar.gz';
|
|
48
|
+
const tmpDir = '/tmp/openspec-e2e-update';
|
|
49
|
+
|
|
50
|
+
execSync(
|
|
51
|
+
`curl -sL https://github.com/wxhou/openspec-playwright/archive/refs/heads/main.tar.gz -o ${tmpSkill}`,
|
|
52
|
+
{ stdio: 'pipe' }
|
|
53
|
+
);
|
|
54
|
+
// Clean and re-extract to avoid stale files from previous runs
|
|
55
|
+
execSync(`rm -rf ${tmpDir} && mkdir -p ${tmpDir} && tar -xzf ${tmpSkill} -C ${tmpDir} --strip-components=1`, { stdio: 'pipe' });
|
|
56
|
+
|
|
57
|
+
const skillSrc = `${tmpDir}/.claude/skills/openspec-e2e/SKILL.md`;
|
|
58
|
+
const cmdSrc = `${tmpDir}/.claude/commands/opsx/e2e.md`;
|
|
59
|
+
const schemaSrc = `${tmpDir}/schemas/playwright-e2e`;
|
|
60
|
+
|
|
61
|
+
installSkillFrom(skillSrc, cmdSrc, schemaSrc, projectRoot);
|
|
62
|
+
console.log(chalk.green(' ā Skill & command updated to latest'));
|
|
63
|
+
} catch {
|
|
64
|
+
console.log(chalk.yellow(' ā Failed to update skill/command from git'));
|
|
65
|
+
console.log(chalk.gray(' Running from npm package instead...'));
|
|
66
|
+
installSkill(projectRoot);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Summary
|
|
71
|
+
console.log(chalk.blue('\nāāā Summary āāā'));
|
|
72
|
+
console.log(chalk.green(' ā Update complete!\n'));
|
|
73
|
+
|
|
74
|
+
console.log(chalk.bold('Restart Claude Code to use the updated skill.'));
|
|
75
|
+
console.log(chalk.gray(' Then run /opsx:e2e <change-name> to verify.\n'));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function installSkill(projectRoot: string) {
|
|
79
|
+
installSkillFrom(
|
|
80
|
+
SKILL_SRC,
|
|
81
|
+
CMD_SRC,
|
|
82
|
+
SCHEMA_DIR + '/playwright-e2e',
|
|
83
|
+
projectRoot
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function installSkillFrom(skillSrc: string, cmdSrc: string, schemaSrc: string, projectRoot: string) {
|
|
88
|
+
const skillDir = join(projectRoot, '.claude', 'skills', 'openspec-e2e');
|
|
89
|
+
const cmdDir = join(projectRoot, '.claude', 'commands');
|
|
90
|
+
|
|
91
|
+
mkdirSync(skillDir, { recursive: true });
|
|
92
|
+
const skillContent = readFileSync(skillSrc, 'utf-8');
|
|
93
|
+
writeFileSync(join(skillDir, 'SKILL.md'), skillContent);
|
|
94
|
+
console.log(chalk.green(` ā Skill updated: /openspec-e2e`));
|
|
95
|
+
|
|
96
|
+
mkdirSync(join(cmdDir, 'opsx'), { recursive: true });
|
|
97
|
+
const cmdContent = readFileSync(cmdSrc, 'utf-8');
|
|
98
|
+
writeFileSync(join(cmdDir, 'opsx', 'e2e.md'), cmdContent);
|
|
99
|
+
console.log(chalk.green(` ā Command updated: /opsx:e2e`));
|
|
100
|
+
|
|
101
|
+
installSchemaFrom(schemaSrc, projectRoot);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function installSchemaFrom(schemaSrc: string, projectRoot: string) {
|
|
105
|
+
const schemaDest = join(projectRoot, 'openspec', 'schemas', 'playwright-e2e');
|
|
106
|
+
|
|
107
|
+
mkdirSync(schemaDest, { recursive: true });
|
|
108
|
+
// Copy schema.yaml
|
|
109
|
+
const schemaYamlSrc = join(schemaSrc, 'schema.yaml');
|
|
110
|
+
if (existsSync(schemaYamlSrc)) {
|
|
111
|
+
writeFileSync(join(schemaDest, 'schema.yaml'), readFileSync(schemaYamlSrc));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Copy templates
|
|
115
|
+
const templatesSrc = join(schemaSrc, 'templates');
|
|
116
|
+
const templatesDest = join(schemaDest, 'templates');
|
|
117
|
+
if (existsSync(templatesSrc)) {
|
|
118
|
+
mkdirSync(templatesDest, { recursive: true });
|
|
119
|
+
const templateFiles = ['test-plan.md', 'report.md', 'e2e-test.ts', 'playwright.config.ts'];
|
|
120
|
+
for (const file of templateFiles) {
|
|
121
|
+
const src = join(templatesSrc, file);
|
|
122
|
+
const dest = join(templatesDest, file);
|
|
123
|
+
if (existsSync(src)) {
|
|
124
|
+
writeFileSync(dest, readFileSync(src));
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
console.log(chalk.green(' ā Schema updated: openspec/schemas/playwright-e2e/'));
|
|
130
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { readFileSync } from 'fs';
|
|
2
|
+
import { join, dirname } from 'path';
|
|
3
|
+
import { fileURLToPath } from 'url';
|
|
4
|
+
import { Command } from 'commander';
|
|
5
|
+
import { init } from './commands/init.js';
|
|
6
|
+
import { update } from './commands/update.js';
|
|
7
|
+
import { doctor } from './commands/doctor.js';
|
|
8
|
+
import { run } from './commands/run.js';
|
|
9
|
+
|
|
10
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
11
|
+
const pkg = JSON.parse(readFileSync(join(__dirname, '../package.json'), 'utf-8'));
|
|
12
|
+
|
|
13
|
+
const program = new Command();
|
|
14
|
+
|
|
15
|
+
program
|
|
16
|
+
.name('openspec-pw')
|
|
17
|
+
.description('OpenSpec + Playwright E2E verification setup tool')
|
|
18
|
+
.version(pkg.version);
|
|
19
|
+
|
|
20
|
+
program
|
|
21
|
+
.command('init')
|
|
22
|
+
.description('Initialize OpenSpec + Playwright E2E integration in the current project')
|
|
23
|
+
.option('-c, --change <name>', 'default change name', 'default')
|
|
24
|
+
.option('--no-mcp', 'skip Playwright MCP configuration')
|
|
25
|
+
.option('--no-seed', 'skip seed test generation')
|
|
26
|
+
.action(init);
|
|
27
|
+
|
|
28
|
+
program
|
|
29
|
+
.command('doctor')
|
|
30
|
+
.description('Check if all prerequisites are installed')
|
|
31
|
+
.action(doctor);
|
|
32
|
+
|
|
33
|
+
program
|
|
34
|
+
.command('update')
|
|
35
|
+
.description('Update the CLI tool and skill to the latest version')
|
|
36
|
+
.option('--no-cli', 'skip CLI update')
|
|
37
|
+
.option('--no-skill', 'skip skill/command update')
|
|
38
|
+
.action(update);
|
|
39
|
+
|
|
40
|
+
program
|
|
41
|
+
.command('run <change-name>')
|
|
42
|
+
.description('Run Playwright E2E tests for an OpenSpec change')
|
|
43
|
+
.option('-p, --project <name>', 'Playwright project to run (e.g., user, admin)')
|
|
44
|
+
.option('-t, --timeout <seconds>', 'Test timeout in seconds', '300')
|
|
45
|
+
.action(run);
|
|
46
|
+
|
|
47
|
+
program.parse();
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// Auth setup for Playwright Test Agents
|
|
2
|
+
// This file authenticates once and saves the session state to .auth/user.json
|
|
3
|
+
// All subsequent tests reuse this state ā no repeated logins needed.
|
|
4
|
+
//
|
|
5
|
+
// Setup:
|
|
6
|
+
// 1. Set credentials: export E2E_USERNAME=xxx E2E_PASSWORD=yyy
|
|
7
|
+
// 2. Run: npx playwright test --project=setup
|
|
8
|
+
// (if UI login: browser opens ā log in manually once)
|
|
9
|
+
// 3. Credentials are saved ā tests auto-use the saved session
|
|
10
|
+
//
|
|
11
|
+
// To switch users, update credentials and re-run step 2.
|
|
12
|
+
//
|
|
13
|
+
// For multi-user testing, add more setup blocks with different storageState paths.
|
|
14
|
+
|
|
15
|
+
import { test as setup, expect } from '@playwright/test';
|
|
16
|
+
|
|
17
|
+
// āāā API Login (preferred) āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
18
|
+
// If your app has a login API, configure it in tests/playwright/credentials.yaml:
|
|
19
|
+
// api: /api/auth/login
|
|
20
|
+
// Then this block runs automatically.
|
|
21
|
+
|
|
22
|
+
setup('authenticate via API', async ({ page }) => {
|
|
23
|
+
const baseUrl = process.env.BASE_URL || 'http://localhost:3000';
|
|
24
|
+
const username = process.env.E2E_USERNAME;
|
|
25
|
+
const password = process.env.E2E_PASSWORD;
|
|
26
|
+
|
|
27
|
+
if (!username || !password) {
|
|
28
|
+
console.warn('ā E2E_USERNAME or E2E_PASSWORD not set ā skipping API auth');
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const res = await page.request.post(`${baseUrl}/api/auth/login`, {
|
|
33
|
+
data: { username, password },
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
if (!res.ok()) {
|
|
37
|
+
throw new Error(`API login failed (${res.status()}). Check credentials.`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
await page.context().storageState({ path: './playwright/.auth/user.json' });
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
// āāā UI Login (fallback) āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
44
|
+
// If no login API, use UI login. Update selectors to match your login page.
|
|
45
|
+
|
|
46
|
+
setup('authenticate via UI', async ({ page }) => {
|
|
47
|
+
const baseUrl = process.env.BASE_URL || 'http://localhost:3000';
|
|
48
|
+
const username = process.env.E2E_USERNAME;
|
|
49
|
+
const password = process.env.E2E_PASSWORD;
|
|
50
|
+
|
|
51
|
+
if (!username || !password) {
|
|
52
|
+
console.warn('ā E2E_USERNAME or E2E_PASSWORD not set ā skipping UI auth');
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// TODO: Update these selectors to match your login page
|
|
57
|
+
// Preferred: add data-testid attributes to your login form fields
|
|
58
|
+
await page.goto(`${baseUrl}/login`);
|
|
59
|
+
|
|
60
|
+
// Common selector patterns (uncomment the one that matches):
|
|
61
|
+
await page.fill('[data-testid="username"]', username);
|
|
62
|
+
// await page.fill('input[name="email"]', username);
|
|
63
|
+
// await page.fill('input[type="email"]', username);
|
|
64
|
+
|
|
65
|
+
await page.fill('[data-testid="password"]', password);
|
|
66
|
+
// await page.fill('input[name="password"]', password);
|
|
67
|
+
|
|
68
|
+
await page.click('[data-testid="login-button"]');
|
|
69
|
+
// await page.click('button[type="submit"]');
|
|
70
|
+
// await page.click('text=Sign In');
|
|
71
|
+
|
|
72
|
+
// Wait for redirect after login (adjust pattern to your app)
|
|
73
|
+
await page.waitForURL(/\/(dashboard|home|profile)/);
|
|
74
|
+
await expect(page).not.toHaveURL(/.*login.*|.*signin.*/);
|
|
75
|
+
|
|
76
|
+
await page.context().storageState({ path: './playwright/.auth/user.json' });
|
|
77
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# Playwright E2E Test Credentials
|
|
2
|
+
# Customize these for your application.
|
|
3
|
+
#
|
|
4
|
+
# Setup:
|
|
5
|
+
# 1. Fill in your test user credentials below
|
|
6
|
+
# 2. Export credentials as environment variables:
|
|
7
|
+
# export E2E_USERNAME=your-email@example.com
|
|
8
|
+
# export E2E_PASSWORD=your-password
|
|
9
|
+
# 3. For multi-user tests, add more users with unique names
|
|
10
|
+
# 4. Run: npx playwright test --project=setup
|
|
11
|
+
#
|
|
12
|
+
# API login (preferred): Set `api` to your login endpoint
|
|
13
|
+
# The auth.setup.ts will call POST {api} with username + password
|
|
14
|
+
# Example: api: /api/auth/login
|
|
15
|
+
#
|
|
16
|
+
# UI login (fallback): Leave `api` empty
|
|
17
|
+
# The auth.setup.ts will open the browser and fill the login form
|
|
18
|
+
# Make sure selectors in auth.setup.ts match your login page
|
|
19
|
+
|
|
20
|
+
api: /api/auth/login # Leave empty for UI login
|
|
21
|
+
|
|
22
|
+
users:
|
|
23
|
+
- name: user
|
|
24
|
+
username: test@example.com
|
|
25
|
+
password: testpassword123
|
|
26
|
+
|
|
27
|
+
# Multi-user example (uncomment and configure for role-based tests):
|
|
28
|
+
# - name: admin
|
|
29
|
+
# username: admin@example.com
|
|
30
|
+
# password: adminpassword123
|
|
31
|
+
# - name: premium
|
|
32
|
+
# username: premium@example.com
|
|
33
|
+
# password: premiumpassword123
|