@avantmedia/af 0.0.1 → 0.0.2
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 +2 -1
- package/scripts/e2e_tests.ts +416 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@avantmedia/af",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.2",
|
|
4
4
|
"description": "Development utility.",
|
|
5
5
|
"homepage": "https://github.com/avantmedialtd/artifex#readme",
|
|
6
6
|
"bugs": {
|
|
@@ -39,6 +39,7 @@
|
|
|
39
39
|
"generated/",
|
|
40
40
|
"setup/",
|
|
41
41
|
"resources/",
|
|
42
|
+
"scripts/e2e_tests.ts",
|
|
42
43
|
"!**/*.test.ts"
|
|
43
44
|
],
|
|
44
45
|
"main": "main.ts",
|
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
// @ts-nocheck
|
|
2
|
+
/**
|
|
3
|
+
* E2E Test Runner
|
|
4
|
+
*
|
|
5
|
+
* Runs Playwright E2E tests in a fresh Docker environment with full isolation.
|
|
6
|
+
* Pass a full shell command to run inside the e2e container.
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* af e2e # Run all tests with defaults
|
|
10
|
+
* af e2e npm run e2e -- --grep "booking" # Custom command
|
|
11
|
+
*
|
|
12
|
+
* Defaults (when no command provided):
|
|
13
|
+
* npm run e2e -- --workers 2 --max-failures=1
|
|
14
|
+
*
|
|
15
|
+
* Always appended:
|
|
16
|
+
* --reporter=./copy-prompt-reporter.ts,html # AI-friendly + HTML reports
|
|
17
|
+
*
|
|
18
|
+
* Environment:
|
|
19
|
+
* CI=1 # Show verbose command output
|
|
20
|
+
* PROJECT_NAME=name # Docker Compose project name (for isolation)
|
|
21
|
+
*
|
|
22
|
+
* Output directories:
|
|
23
|
+
* ./playwright-report/ # HTML report with traces and DOM snapshots
|
|
24
|
+
* ./test-results/ # Screenshots (viewable via Read tool for AI agents)
|
|
25
|
+
*/
|
|
26
|
+
import { spawn } from 'node:child_process';
|
|
27
|
+
import fs from 'node:fs';
|
|
28
|
+
import { tmpdir } from 'node:os';
|
|
29
|
+
import path from 'node:path';
|
|
30
|
+
import { extractResource } from '../utils/resources.ts';
|
|
31
|
+
|
|
32
|
+
const SHOW_AGENT_DETAILS = !!process.env.CI;
|
|
33
|
+
const SHOULD_SHOW_SPINNER = !process.env.CI;
|
|
34
|
+
|
|
35
|
+
// Module-level args variable, set by runE2eTests or standalone execution
|
|
36
|
+
let passThroughArgs: string[] = [];
|
|
37
|
+
|
|
38
|
+
function composeArgs(args: string[]): string[] {
|
|
39
|
+
const out: string[] = ['compose'];
|
|
40
|
+
const projectName = process.env.PROJECT_NAME;
|
|
41
|
+
if (projectName && projectName.trim().length > 0) {
|
|
42
|
+
out.push('-p', projectName.trim());
|
|
43
|
+
}
|
|
44
|
+
out.push('-f', 'docker-compose.yml');
|
|
45
|
+
out.push('-f', 'docker-compose.test.yml');
|
|
46
|
+
out.push('--profile', 'testing');
|
|
47
|
+
|
|
48
|
+
out.push(...args);
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function compose(
|
|
53
|
+
args: string[],
|
|
54
|
+
opts?: {
|
|
55
|
+
showSpinner?: boolean;
|
|
56
|
+
inheritStdio?: boolean;
|
|
57
|
+
allowFailure?: boolean;
|
|
58
|
+
},
|
|
59
|
+
) {
|
|
60
|
+
const fullArgs = composeArgs(args);
|
|
61
|
+
const runOpts: SpawnOpts & { showSpinner?: boolean } = {
|
|
62
|
+
showSpinner: opts?.showSpinner,
|
|
63
|
+
inheritStdio: opts?.inheritStdio,
|
|
64
|
+
};
|
|
65
|
+
if (opts?.allowFailure) {
|
|
66
|
+
return runAllowFailure('docker', fullArgs, runOpts);
|
|
67
|
+
}
|
|
68
|
+
return run('docker', fullArgs, runOpts);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const clearPreviousLine = () => {
|
|
72
|
+
process.stdout.write('\x1b[1A\x1b[2K');
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
// Colors for output
|
|
76
|
+
const RED = '\u001b[0;31m';
|
|
77
|
+
const GREEN = '\u001b[0;32m';
|
|
78
|
+
const BLUE = '\u001b[0;34m';
|
|
79
|
+
const YELLOW = '\u001b[1;33m';
|
|
80
|
+
const GRAY = '\u001b[0;90m';
|
|
81
|
+
const BOLD = '\u001b[1m';
|
|
82
|
+
const NC = '\u001b[0m';
|
|
83
|
+
|
|
84
|
+
// Progress tracking
|
|
85
|
+
const TOTAL_STEPS = 4;
|
|
86
|
+
let CURRENT_STEP = 0;
|
|
87
|
+
|
|
88
|
+
// Timing
|
|
89
|
+
const START_TIME = Date.now();
|
|
90
|
+
|
|
91
|
+
// Ensure we run from repo root
|
|
92
|
+
const repoRoot = process.cwd();
|
|
93
|
+
|
|
94
|
+
function elapsedSeconds(from: number) {
|
|
95
|
+
return Math.floor((Date.now() - from) / 1000);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function formatDuration(seconds: number): string {
|
|
99
|
+
if (seconds < 60) {
|
|
100
|
+
return `${seconds}s`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const hours = Math.floor(seconds / 3600);
|
|
104
|
+
const minutes = Math.floor((seconds % 3600) / 60);
|
|
105
|
+
const secs = seconds % 60;
|
|
106
|
+
|
|
107
|
+
if (hours > 0) {
|
|
108
|
+
return `${hours}h ${minutes}m ${secs}s`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return `${minutes}m ${secs}s`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function logStep(msg: string) {
|
|
115
|
+
CURRENT_STEP += 1;
|
|
116
|
+
const elapsed = elapsedSeconds(START_TIME);
|
|
117
|
+
process.stdout.write(
|
|
118
|
+
`\n${BOLD}${BLUE}[${CURRENT_STEP}/${TOTAL_STEPS}]${NC} ${BOLD}${msg}${NC} ${GRAY}(${formatDuration(
|
|
119
|
+
elapsed,
|
|
120
|
+
)})${NC}\n`,
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function logInfo(msg: string) {
|
|
125
|
+
process.stdout.write(` ${BLUE}→${NC} ${msg}\n`);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function logSuccess(msg: string) {
|
|
129
|
+
process.stdout.write(` ${GREEN}✓${NC} ${msg}\n`);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function logWarning(msg: string) {
|
|
133
|
+
process.stdout.write(` ${YELLOW}⚠${NC} ${msg}\n`);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function logError(msg: string) {
|
|
137
|
+
process.stdout.write(` ${RED}✗${NC} ${msg}\n`);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
type SpawnOpts = {
|
|
141
|
+
cwd?: string;
|
|
142
|
+
env?: NodeJS.ProcessEnv;
|
|
143
|
+
inheritStdio?: boolean; // when true, stream child output to current terminal
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
function showSpinnerUntil(child: import('node:child_process').ChildProcess): Promise<number> {
|
|
147
|
+
return new Promise(resolve => {
|
|
148
|
+
const frames = ['|', '/', '-', '\\'];
|
|
149
|
+
let i = 0;
|
|
150
|
+
const timer = setInterval(() => {
|
|
151
|
+
const f = frames[i++ % frames.length];
|
|
152
|
+
process.stdout.write(` [${f}] `);
|
|
153
|
+
// backspaces to erase spinner
|
|
154
|
+
process.stdout.write('\b\b\b\b\b\b');
|
|
155
|
+
}, 100);
|
|
156
|
+
|
|
157
|
+
const finish = (code: number | null) => {
|
|
158
|
+
clearInterval(timer);
|
|
159
|
+
// Clear remaining spinner chars
|
|
160
|
+
process.stdout.write(' \b\b\b\b');
|
|
161
|
+
resolve(code ?? 0);
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
child.on('error', () => finish(1));
|
|
165
|
+
child.on('close', code => finish(code));
|
|
166
|
+
child.on('exit', code => finish(code));
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function run(
|
|
171
|
+
cmd: string,
|
|
172
|
+
args: string[],
|
|
173
|
+
opts: SpawnOpts & { showSpinner?: boolean } = {},
|
|
174
|
+
): Promise<number> {
|
|
175
|
+
return new Promise((resolve, reject) => {
|
|
176
|
+
if (SHOW_AGENT_DETAILS) {
|
|
177
|
+
console.log(
|
|
178
|
+
`Running command: ${cmd} ${args
|
|
179
|
+
.map(a => (a.includes(' ') ? `"${a}"` : a))
|
|
180
|
+
.join(' ')}`,
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const child = spawn(cmd, args, {
|
|
185
|
+
cwd: opts.cwd ?? process.cwd(),
|
|
186
|
+
env: opts.env ?? process.env,
|
|
187
|
+
stdio: opts.inheritStdio ? 'inherit' : 'ignore',
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
if (opts.showSpinner) {
|
|
191
|
+
showSpinnerUntil(child).then(code => {
|
|
192
|
+
if (code === 0) resolve(0);
|
|
193
|
+
else reject(new Error(`${cmd} ${args.join(' ')} exited with code ${code}`));
|
|
194
|
+
});
|
|
195
|
+
} else {
|
|
196
|
+
child.on('error', err => reject(err));
|
|
197
|
+
child.on('close', code => {
|
|
198
|
+
if (code === 0) resolve(0);
|
|
199
|
+
else reject(new Error(`${cmd} ${args.join(' ')} exited with code ${code}`));
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function runAllowFailure(
|
|
206
|
+
cmd: string,
|
|
207
|
+
args: string[],
|
|
208
|
+
opts: SpawnOpts & { showSpinner?: boolean } = {},
|
|
209
|
+
): Promise<number> {
|
|
210
|
+
if (SHOW_AGENT_DETAILS) {
|
|
211
|
+
console.log(
|
|
212
|
+
`Running command: ${cmd} ${args.map(a => (a.includes(' ') ? `"${a}"` : a)).join(' ')}`,
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return new Promise(resolve => {
|
|
217
|
+
const child = spawn(cmd, args, {
|
|
218
|
+
cwd: opts.cwd ?? process.cwd(),
|
|
219
|
+
env: opts.env ?? process.env,
|
|
220
|
+
stdio: opts.inheritStdio ? 'inherit' : 'ignore',
|
|
221
|
+
});
|
|
222
|
+
const finish = (code: number | null) => resolve(code ?? 0);
|
|
223
|
+
|
|
224
|
+
if (opts.showSpinner) {
|
|
225
|
+
showSpinnerUntil(child).then(code => finish(code));
|
|
226
|
+
} else {
|
|
227
|
+
child.on('error', () => finish(1));
|
|
228
|
+
child.on('close', code => finish(code));
|
|
229
|
+
child.on('exit', code => finish(code));
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export async function runE2eTests(args: string[]): Promise<number> {
|
|
235
|
+
passThroughArgs = args;
|
|
236
|
+
// Welcome message
|
|
237
|
+
process.stdout.write(`\n${BOLD}${BLUE}🔬 End to end tests${NC}\n`);
|
|
238
|
+
process.stdout.write(
|
|
239
|
+
`${GRAY}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}\n`,
|
|
240
|
+
);
|
|
241
|
+
|
|
242
|
+
// Step 1: Environment setup
|
|
243
|
+
logStep('Setting up fresh environment');
|
|
244
|
+
logInfo('Tearing down existing containers');
|
|
245
|
+
await compose(['down', '-v'], { allowFailure: true });
|
|
246
|
+
|
|
247
|
+
clearPreviousLine();
|
|
248
|
+
logSuccess('Cleaned up existing environment');
|
|
249
|
+
|
|
250
|
+
if (process.env.CLEAN_DOCKER_VOLUMES === '1') {
|
|
251
|
+
logInfo('Pruning cached Docker images');
|
|
252
|
+
// Prune only images from this compose project (uses PROJECT_NAME or directory name)
|
|
253
|
+
const composeProjectName = process.env.PROJECT_NAME?.trim() || path.basename(repoRoot);
|
|
254
|
+
await run(
|
|
255
|
+
'docker',
|
|
256
|
+
[
|
|
257
|
+
'image',
|
|
258
|
+
'prune',
|
|
259
|
+
'-f',
|
|
260
|
+
'--filter',
|
|
261
|
+
`label=com.docker.compose.project=${composeProjectName}`,
|
|
262
|
+
],
|
|
263
|
+
{ showSpinner: SHOULD_SHOW_SPINNER },
|
|
264
|
+
);
|
|
265
|
+
|
|
266
|
+
clearPreviousLine();
|
|
267
|
+
logSuccess('Docker image cache cleared');
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
logInfo('Building and starting services');
|
|
271
|
+
await compose(['up', '-d', '--build', '--wait'], {
|
|
272
|
+
inheritStdio: SHOW_AGENT_DETAILS,
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
clearPreviousLine();
|
|
276
|
+
logSuccess('All services ready');
|
|
277
|
+
|
|
278
|
+
// Copy reporter to container
|
|
279
|
+
logInfo('Copying test reporter to container');
|
|
280
|
+
const tempReporterPath = path.join(tmpdir(), 'copy-prompt-reporter.ts');
|
|
281
|
+
try {
|
|
282
|
+
await extractResource('copy-prompt-reporter.ts', tempReporterPath);
|
|
283
|
+
const copyReporterCode = await compose(
|
|
284
|
+
['cp', tempReporterPath, 'e2e:/workspace/copy-prompt-reporter.ts'],
|
|
285
|
+
{ allowFailure: false },
|
|
286
|
+
);
|
|
287
|
+
if (copyReporterCode !== 0) {
|
|
288
|
+
throw new Error(`docker compose cp failed with exit code ${copyReporterCode}`);
|
|
289
|
+
}
|
|
290
|
+
clearPreviousLine();
|
|
291
|
+
logSuccess('Test reporter copied to container');
|
|
292
|
+
} catch (err) {
|
|
293
|
+
logError(`Failed to copy test reporter: ${err instanceof Error ? err.message : err}`);
|
|
294
|
+
process.exit(1);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Step 2: Run E2E tests
|
|
298
|
+
logStep('Running E2E tests');
|
|
299
|
+
logInfo('Executing Playwright test suite');
|
|
300
|
+
if (passThroughArgs.length > 0) {
|
|
301
|
+
logInfo(`Command: ${passThroughArgs.join(' ')}`);
|
|
302
|
+
}
|
|
303
|
+
const TEST_START = Date.now();
|
|
304
|
+
|
|
305
|
+
// Build test command - reporter is always appended
|
|
306
|
+
const REPORTER = '--reporter=./copy-prompt-reporter.ts,html';
|
|
307
|
+
let testCommand: string;
|
|
308
|
+
if (passThroughArgs.length > 0) {
|
|
309
|
+
testCommand = `${passThroughArgs.join(' ')} ${REPORTER}`;
|
|
310
|
+
} else {
|
|
311
|
+
testCommand = `npm run e2e -- --workers 2 --max-failures=1 ${REPORTER}`;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// We allow failure to capture exit code without throwing
|
|
315
|
+
const e2eExitCode = await compose(['exec', '-T', 'e2e', 'sh', '-c', testCommand], {
|
|
316
|
+
inheritStdio: true,
|
|
317
|
+
allowFailure: true,
|
|
318
|
+
});
|
|
319
|
+
const TEST_DURATION = elapsedSeconds(TEST_START);
|
|
320
|
+
|
|
321
|
+
if (e2eExitCode === 0) {
|
|
322
|
+
logSuccess(`All tests passed (${formatDuration(TEST_DURATION)})`);
|
|
323
|
+
} else {
|
|
324
|
+
logError(`Tests completed with failures (${formatDuration(TEST_DURATION)})`);
|
|
325
|
+
const dockerArgsStr = composeArgs(['logs', '--no-color']).join(' ');
|
|
326
|
+
await runAllowFailure('sh', ['-c', `docker ${dockerArgsStr} > docker.log 2>&1`]);
|
|
327
|
+
logInfo('Logs saved to docker.log');
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Step 3: Generate test report
|
|
331
|
+
logStep('Generating test report');
|
|
332
|
+
logInfo('Copying Playwright report and test results');
|
|
333
|
+
|
|
334
|
+
try {
|
|
335
|
+
fs.rmSync('./playwright-report', { recursive: true, force: true });
|
|
336
|
+
fs.rmSync('./test-results', { recursive: true, force: true });
|
|
337
|
+
} catch {
|
|
338
|
+
// ignore
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const copyCode = await compose(
|
|
342
|
+
['cp', 'e2e:/workspace/playwright-report', './playwright-report'],
|
|
343
|
+
{
|
|
344
|
+
allowFailure: true,
|
|
345
|
+
},
|
|
346
|
+
);
|
|
347
|
+
if (copyCode === 0) {
|
|
348
|
+
logSuccess('Test report saved to ./playwright-report');
|
|
349
|
+
if (fs.existsSync('./playwright-report/index.html')) {
|
|
350
|
+
logSuccess('HTML report available: ./playwright-report/index.html');
|
|
351
|
+
}
|
|
352
|
+
} else {
|
|
353
|
+
logWarning('Could not copy test report');
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// Copy test-results (screenshots, traces) for AI agent access
|
|
357
|
+
const copyResultsCode = await compose(['cp', 'e2e:/workspace/test-results', './test-results'], {
|
|
358
|
+
allowFailure: true,
|
|
359
|
+
});
|
|
360
|
+
if (copyResultsCode === 0 && fs.existsSync('./test-results')) {
|
|
361
|
+
logSuccess('Test results (screenshots/traces) saved to ./test-results');
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// Step 4: Summary
|
|
365
|
+
logStep('Test run complete');
|
|
366
|
+
const TOTAL_DURATION = elapsedSeconds(START_TIME);
|
|
367
|
+
|
|
368
|
+
logInfo('Tearing down existing containers');
|
|
369
|
+
process.stdout.write('\b'.repeat('Tearing down existing containers'.length + 2));
|
|
370
|
+
|
|
371
|
+
await compose(['down', '-v'], { allowFailure: true });
|
|
372
|
+
|
|
373
|
+
process.stdout.write(`\n${BOLD}${BLUE}📊 Test Summary${NC}\n`);
|
|
374
|
+
process.stdout.write(
|
|
375
|
+
`${GRAY}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}\n`,
|
|
376
|
+
);
|
|
377
|
+
|
|
378
|
+
if (e2eExitCode === 0) {
|
|
379
|
+
process.stdout.write(` ${GREEN}✓${NC} ${BOLD}All tests passed${NC}\n`);
|
|
380
|
+
process.stdout.write(` ${GRAY}→${NC} Total time: ${formatDuration(TOTAL_DURATION)}\n`);
|
|
381
|
+
process.stdout.write(` ${GRAY}→${NC} Test time: ${formatDuration(TEST_DURATION)}\n`);
|
|
382
|
+
process.stdout.write(`\n${GREEN}🎉 E2E tests completed successfully!${NC}\n`);
|
|
383
|
+
} else {
|
|
384
|
+
process.stdout.write(` ${RED}✗${NC} ${BOLD}Tests failed${NC}\n`);
|
|
385
|
+
process.stdout.write(` ${GRAY}→${NC} Total time: ${formatDuration(TOTAL_DURATION)}\n`);
|
|
386
|
+
process.stdout.write(` ${GRAY}→${NC} Test time: ${formatDuration(TEST_DURATION)}\n`);
|
|
387
|
+
process.stdout.write(` ${GRAY}→${NC} Exit code: ${e2eExitCode}\n`);
|
|
388
|
+
if (fs.existsSync('./playwright-report/index.html')) {
|
|
389
|
+
process.stdout.write(` ${GRAY}→${NC} HTML report: ./playwright-report/index.html\n`);
|
|
390
|
+
}
|
|
391
|
+
process.stdout.write(`\n${RED}❌ E2E tests failed. Check the report for details.${NC}\n`);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
process.stdout.write('\n');
|
|
395
|
+
return e2eExitCode;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// Ensure we attempt to tear down on Ctrl+C
|
|
399
|
+
process.on('SIGINT', async () => {
|
|
400
|
+
try {
|
|
401
|
+
await compose(['down', '-v'], { allowFailure: true });
|
|
402
|
+
} finally {
|
|
403
|
+
process.exit(130);
|
|
404
|
+
}
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
// Standalone execution for direct script usage
|
|
408
|
+
if (import.meta.main) {
|
|
409
|
+
runE2eTests(process.argv.slice(2))
|
|
410
|
+
.then(code => process.exit(code))
|
|
411
|
+
.catch(err => {
|
|
412
|
+
logError(err?.message ?? String(err));
|
|
413
|
+
// best-effort teardown
|
|
414
|
+
compose(['down', '-v'], { allowFailure: true }).finally(() => process.exit(1));
|
|
415
|
+
});
|
|
416
|
+
}
|