canary-lab 0.1.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/LICENSE +21 -0
- package/README.md +282 -0
- package/dist/feature-support/load-env.d.ts +1 -0
- package/dist/feature-support/load-env.js +5 -0
- package/dist/feature-support/log-marker-fixture.d.ts +1 -0
- package/dist/feature-support/log-marker-fixture.js +6 -0
- package/dist/feature-support/playwright-base.d.ts +1 -0
- package/dist/feature-support/playwright-base.js +5 -0
- package/dist/feature-support/types.d.ts +1 -0
- package/dist/feature-support/types.js +2 -0
- package/dist/scripts/cli.d.ts +2 -0
- package/dist/scripts/cli.js +47 -0
- package/dist/scripts/init-project.d.ts +1 -0
- package/dist/scripts/init-project.js +110 -0
- package/dist/scripts/new-feature.d.ts +1 -0
- package/dist/scripts/new-feature.js +135 -0
- package/dist/shared/configs/loadEnv.d.ts +5 -0
- package/dist/shared/configs/loadEnv.js +19 -0
- package/dist/shared/configs/playwright.base.d.ts +12 -0
- package/dist/shared/configs/playwright.base.js +15 -0
- package/dist/shared/e2e-runner/log-marker-fixture.d.ts +13 -0
- package/dist/shared/e2e-runner/log-marker-fixture.js +45 -0
- package/dist/shared/e2e-runner/paths.d.ts +8 -0
- package/dist/shared/e2e-runner/paths.js +16 -0
- package/dist/shared/e2e-runner/runner.d.ts +1 -0
- package/dist/shared/e2e-runner/runner.js +567 -0
- package/dist/shared/e2e-runner/summary-reporter.d.ts +7 -0
- package/dist/shared/e2e-runner/summary-reporter.js +33 -0
- package/dist/shared/env-switcher/root-cli.d.ts +1 -0
- package/dist/shared/env-switcher/root-cli.js +84 -0
- package/dist/shared/env-switcher/switch.d.ts +1 -0
- package/dist/shared/env-switcher/switch.js +249 -0
- package/dist/shared/env-switcher/types.d.ts +18 -0
- package/dist/shared/env-switcher/types.js +2 -0
- package/dist/shared/launcher/iterm.d.ts +2 -0
- package/dist/shared/launcher/iterm.js +28 -0
- package/dist/shared/launcher/startup.d.ts +9 -0
- package/dist/shared/launcher/startup.js +40 -0
- package/dist/shared/launcher/terminal.d.ts +2 -0
- package/dist/shared/launcher/terminal.js +25 -0
- package/dist/shared/launcher/types.d.ts +28 -0
- package/dist/shared/launcher/types.js +2 -0
- package/dist/shared/runtime/project-root.d.ts +2 -0
- package/dist/shared/runtime/project-root.js +32 -0
- package/dist/templates/project/.claude/skills/self-fixing-loop.md +53 -0
- package/dist/templates/project/.codex/self-fixing-loop.md +49 -0
- package/dist/templates/project/AGENTS.md +27 -0
- package/dist/templates/project/CLAUDE.md +31 -0
- package/dist/templates/project/features/broken_todo_api/.env.example +1 -0
- package/dist/templates/project/features/broken_todo_api/e2e/broken-todo-api.spec.js +34 -0
- package/dist/templates/project/features/broken_todo_api/e2e/helpers/api.js +48 -0
- package/dist/templates/project/features/broken_todo_api/envsets/envsets.config.json +14 -0
- package/dist/templates/project/features/broken_todo_api/envsets/local/broken_todo_api.env +1 -0
- package/dist/templates/project/features/broken_todo_api/feature.config.cjs +24 -0
- package/dist/templates/project/features/broken_todo_api/playwright.config.js +6 -0
- package/dist/templates/project/features/broken_todo_api/scripts/server.js +76 -0
- package/dist/templates/project/features/broken_todo_api/src/config.js +9 -0
- package/dist/templates/project/features/example_todo_api/.env.example +1 -0
- package/dist/templates/project/features/example_todo_api/e2e/helpers/api.js +36 -0
- package/dist/templates/project/features/example_todo_api/e2e/todo-api.spec.js +25 -0
- package/dist/templates/project/features/example_todo_api/envsets/envsets.config.json +14 -0
- package/dist/templates/project/features/example_todo_api/envsets/local/example_todo_api.env +1 -0
- package/dist/templates/project/features/example_todo_api/feature.config.cjs +24 -0
- package/dist/templates/project/features/example_todo_api/playwright.config.js +6 -0
- package/dist/templates/project/features/example_todo_api/scripts/server.js +60 -0
- package/dist/templates/project/features/example_todo_api/src/config.js +9 -0
- package/package.json +71 -0
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.baseConfig = void 0;
|
|
4
|
+
exports.baseConfig = {
|
|
5
|
+
testDir: './e2e',
|
|
6
|
+
fullyParallel: false,
|
|
7
|
+
workers: 1,
|
|
8
|
+
retries: 0,
|
|
9
|
+
reporter: [['list']],
|
|
10
|
+
timeout: 90_000,
|
|
11
|
+
use: {
|
|
12
|
+
trace: 'retain-on-failure',
|
|
13
|
+
screenshot: 'only-on-failure',
|
|
14
|
+
},
|
|
15
|
+
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { expect } from '@playwright/test';
|
|
2
|
+
/**
|
|
3
|
+
* Extended Playwright `test` that writes XML markers into every service log
|
|
4
|
+
* listed in logs/manifest.json. If the manifest doesn't exist because tests
|
|
5
|
+
* are run directly with Playwright instead of `canary-lab run`, the fixture
|
|
6
|
+
* is a no-op.
|
|
7
|
+
* https://playwright.dev/docs/extensibility
|
|
8
|
+
*/
|
|
9
|
+
export declare const test: import("@playwright/test").TestType<import("@playwright/test").PlaywrightTestArgs & import("@playwright/test").PlaywrightTestOptions & {
|
|
10
|
+
_logMarker: void;
|
|
11
|
+
}, import("@playwright/test").PlaywrightWorkerArgs & import("@playwright/test").PlaywrightWorkerOptions>;
|
|
12
|
+
export { expect };
|
|
13
|
+
export type { APIRequestContext, Page } from '@playwright/test';
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.expect = exports.test = void 0;
|
|
7
|
+
const test_1 = require("@playwright/test");
|
|
8
|
+
Object.defineProperty(exports, "expect", { enumerable: true, get: function () { return test_1.expect; } });
|
|
9
|
+
const fs_1 = __importDefault(require("fs"));
|
|
10
|
+
const paths_1 = require("./paths");
|
|
11
|
+
function slugify(title) {
|
|
12
|
+
return title
|
|
13
|
+
.toLowerCase()
|
|
14
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
15
|
+
.replace(/^-|-$/g, '');
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Extended Playwright `test` that writes XML markers into every service log
|
|
19
|
+
* listed in logs/manifest.json. If the manifest doesn't exist because tests
|
|
20
|
+
* are run directly with Playwright instead of `canary-lab run`, the fixture
|
|
21
|
+
* is a no-op.
|
|
22
|
+
* https://playwright.dev/docs/extensibility
|
|
23
|
+
*/
|
|
24
|
+
exports.test = test_1.test.extend({
|
|
25
|
+
_logMarker: [
|
|
26
|
+
async ({}, use, testInfo) => {
|
|
27
|
+
if (!fs_1.default.existsSync(paths_1.MANIFEST_PATH)) {
|
|
28
|
+
await use(undefined);
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
const manifest = JSON.parse(fs_1.default.readFileSync(paths_1.MANIFEST_PATH, 'utf-8'));
|
|
32
|
+
const slug = slugify(testInfo.title);
|
|
33
|
+
const openTag = `<test-case-${slug}>\n`;
|
|
34
|
+
const closeTag = `</test-case-${slug}>\n`;
|
|
35
|
+
for (const logPath of manifest.serviceLogs) {
|
|
36
|
+
fs_1.default.appendFileSync(logPath, openTag);
|
|
37
|
+
}
|
|
38
|
+
await use(undefined);
|
|
39
|
+
for (const logPath of manifest.serviceLogs) {
|
|
40
|
+
fs_1.default.appendFileSync(logPath, closeTag);
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
{ auto: true },
|
|
44
|
+
],
|
|
45
|
+
});
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export declare const ROOT: string;
|
|
2
|
+
export declare const FEATURES_DIR: string;
|
|
3
|
+
export declare const LOGS_DIR: string;
|
|
4
|
+
export declare const PIDS_DIR: string;
|
|
5
|
+
export declare const MANIFEST_PATH: string;
|
|
6
|
+
export declare const SUMMARY_PATH: string;
|
|
7
|
+
export declare const RERUN_SIGNAL: string;
|
|
8
|
+
export declare const RESTART_SIGNAL: string;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.RESTART_SIGNAL = exports.RERUN_SIGNAL = exports.SUMMARY_PATH = exports.MANIFEST_PATH = exports.PIDS_DIR = exports.LOGS_DIR = exports.FEATURES_DIR = exports.ROOT = void 0;
|
|
7
|
+
const path_1 = __importDefault(require("path"));
|
|
8
|
+
const project_root_1 = require("../runtime/project-root");
|
|
9
|
+
exports.ROOT = (0, project_root_1.getProjectRoot)();
|
|
10
|
+
exports.FEATURES_DIR = (0, project_root_1.getFeaturesDir)();
|
|
11
|
+
exports.LOGS_DIR = path_1.default.join(exports.ROOT, 'logs');
|
|
12
|
+
exports.PIDS_DIR = path_1.default.join(exports.LOGS_DIR, 'pids');
|
|
13
|
+
exports.MANIFEST_PATH = path_1.default.join(exports.LOGS_DIR, 'manifest.json');
|
|
14
|
+
exports.SUMMARY_PATH = path_1.default.join(exports.LOGS_DIR, 'e2e-summary.json');
|
|
15
|
+
exports.RERUN_SIGNAL = path_1.default.join(exports.LOGS_DIR, '.rerun');
|
|
16
|
+
exports.RESTART_SIGNAL = path_1.default.join(exports.LOGS_DIR, '.restart');
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function main(): Promise<void>;
|
|
@@ -0,0 +1,567 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.main = main;
|
|
7
|
+
const fs_1 = __importDefault(require("fs"));
|
|
8
|
+
const path_1 = __importDefault(require("path"));
|
|
9
|
+
const readline_1 = __importDefault(require("readline"));
|
|
10
|
+
const child_process_1 = require("child_process");
|
|
11
|
+
const startup_1 = require("../launcher/startup");
|
|
12
|
+
const iterm_1 = require("../launcher/iterm");
|
|
13
|
+
const terminal_1 = require("../launcher/terminal");
|
|
14
|
+
const paths_1 = require("./paths");
|
|
15
|
+
// ─── Paths (local to runner) ────────────────────────────────────────────────
|
|
16
|
+
const SWITCH_SCRIPT = path_1.default.join(__dirname, '../env-switcher/switch.js');
|
|
17
|
+
const SUMMARY_REPORTER = path_1.default.resolve(__dirname, 'summary-reporter.js');
|
|
18
|
+
// ─── Readline helpers (same pattern as shared/launcher/index.ts) ────────────
|
|
19
|
+
function prompt(rl, question) {
|
|
20
|
+
return new Promise((resolve) => rl.question(question, resolve));
|
|
21
|
+
}
|
|
22
|
+
async function selectOption(rl, label, options) {
|
|
23
|
+
console.log(`\n${label}`);
|
|
24
|
+
options.forEach((opt, i) => console.log(` ${i + 1}) ${opt}`));
|
|
25
|
+
while (true) {
|
|
26
|
+
const answer = await prompt(rl, `Select [1-${options.length}]: `);
|
|
27
|
+
const idx = parseInt(answer.trim(), 10) - 1;
|
|
28
|
+
if (idx >= 0 && idx < options.length)
|
|
29
|
+
return options[idx];
|
|
30
|
+
console.log(` Please enter a number between 1 and ${options.length}`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
// ─── Feature discovery (same as shared/launcher/index.ts) ───────────────────
|
|
34
|
+
function discoverFeatures() {
|
|
35
|
+
const features = [];
|
|
36
|
+
const dirs = fs_1.default
|
|
37
|
+
.readdirSync(paths_1.FEATURES_DIR, { withFileTypes: true })
|
|
38
|
+
.filter((d) => d.isDirectory())
|
|
39
|
+
.map((d) => d.name);
|
|
40
|
+
for (const dir of dirs) {
|
|
41
|
+
const configPath = ['feature.config.cjs', 'feature.config.js', 'feature.config.ts']
|
|
42
|
+
.map((name) => path_1.default.join(paths_1.FEATURES_DIR, dir, name))
|
|
43
|
+
.find((candidate) => fs_1.default.existsSync(candidate));
|
|
44
|
+
if (!configPath)
|
|
45
|
+
continue;
|
|
46
|
+
try {
|
|
47
|
+
const mod = require(configPath);
|
|
48
|
+
features.push(mod.config ?? mod.default);
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
// skip malformed configs
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return features;
|
|
55
|
+
}
|
|
56
|
+
// ─── Repo check (same as shared/launcher/index.ts) ─────────────────────────
|
|
57
|
+
function checkRepos(feature) {
|
|
58
|
+
if (!feature.repos?.length)
|
|
59
|
+
return true;
|
|
60
|
+
let allOk = true;
|
|
61
|
+
for (const repo of feature.repos) {
|
|
62
|
+
const resolved = (0, startup_1.resolvePath)(repo.localPath);
|
|
63
|
+
if (!fs_1.default.existsSync(resolved)) {
|
|
64
|
+
console.error(`\n Missing repo: ${repo.name}`);
|
|
65
|
+
console.error(` Expected at: ${resolved}`);
|
|
66
|
+
if (repo.cloneUrl) {
|
|
67
|
+
console.error(` Clone it with:\n git clone ${repo.cloneUrl} ${resolved}`);
|
|
68
|
+
}
|
|
69
|
+
allOk = false;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return allOk;
|
|
73
|
+
}
|
|
74
|
+
function buildServiceList(feature) {
|
|
75
|
+
const services = [];
|
|
76
|
+
for (const repo of feature.repos ?? []) {
|
|
77
|
+
const dir = (0, startup_1.resolvePath)(repo.localPath);
|
|
78
|
+
const commands = repo.startCommands ?? [];
|
|
79
|
+
for (let i = 0; i < commands.length; i++) {
|
|
80
|
+
const normalized = (0, startup_1.normalizeStartCommand)(commands[i], `${repo.name}-cmd-${i + 1}`);
|
|
81
|
+
const safeName = normalized
|
|
82
|
+
.name.replace(/[^a-z0-9]+/gi, '-')
|
|
83
|
+
.toLowerCase();
|
|
84
|
+
const logPath = path_1.default.join(paths_1.LOGS_DIR, `svc-${safeName}.log`);
|
|
85
|
+
services.push({
|
|
86
|
+
name: normalized.name,
|
|
87
|
+
safeName,
|
|
88
|
+
logPath,
|
|
89
|
+
command: normalized.command,
|
|
90
|
+
cwd: dir,
|
|
91
|
+
healthUrl: normalized.healthCheck?.url,
|
|
92
|
+
healthTimeout: normalized.healthCheck?.timeoutMs,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return services;
|
|
97
|
+
}
|
|
98
|
+
function buildTeedCommand(svc) {
|
|
99
|
+
// LOG_MODE=plain tells apps to use synchronous console.log instead of async
|
|
100
|
+
// loggers (e.g. Pino/sonic-boom), so XML markers land in the right position.
|
|
101
|
+
// stdout+stderr go to both the iTerm tab and the log file via tee.
|
|
102
|
+
return `LOG_MODE=plain ${svc.command} 2>&1 | tee -a ${svc.logPath}`;
|
|
103
|
+
}
|
|
104
|
+
function openTabs(terminal, tabs, label) {
|
|
105
|
+
if (terminal === 'iTerm') {
|
|
106
|
+
(0, iterm_1.openItermTabs)(tabs, label);
|
|
107
|
+
}
|
|
108
|
+
else {
|
|
109
|
+
(0, terminal_1.openTerminalTabs)(tabs, label);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
async function launchServices(services, terminal) {
|
|
113
|
+
// Always kill existing processes so we own the tee pipe and capture all logs.
|
|
114
|
+
// Without this, externally-started services have no log file and XML markers
|
|
115
|
+
// cannot be extracted.
|
|
116
|
+
for (const svc of services) {
|
|
117
|
+
const pid = resolveRunningPid(svc);
|
|
118
|
+
if (pid) {
|
|
119
|
+
process.stdout.write(` Stopping existing ${svc.name} (PID ${pid})... `);
|
|
120
|
+
await killProcess(pid);
|
|
121
|
+
console.log('stopped');
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
const tabs = [];
|
|
125
|
+
for (const svc of services) {
|
|
126
|
+
// Create/truncate log file for a clean run
|
|
127
|
+
fs_1.default.writeFileSync(svc.logPath, '');
|
|
128
|
+
tabs.push({
|
|
129
|
+
dir: svc.cwd,
|
|
130
|
+
command: buildTeedCommand(svc),
|
|
131
|
+
name: svc.name,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
if (tabs.length === 0) {
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
openTabs(terminal, tabs, ` Opening ${terminal} tabs for ${tabs.length} service(s)...`);
|
|
138
|
+
}
|
|
139
|
+
async function pollHealthChecks(services, timeoutMs = 120_000) {
|
|
140
|
+
const checksNeeded = services.filter((s) => s.healthUrl);
|
|
141
|
+
if (checksNeeded.length === 0)
|
|
142
|
+
return;
|
|
143
|
+
console.log('\n Waiting for health checks...');
|
|
144
|
+
const deadline = Date.now() + timeoutMs;
|
|
145
|
+
for (const svc of checksNeeded) {
|
|
146
|
+
process.stdout.write(` ${svc.name}: `);
|
|
147
|
+
while (Date.now() < deadline) {
|
|
148
|
+
if (await (0, startup_1.isHealthy)(svc.healthUrl, svc.healthTimeout)) {
|
|
149
|
+
console.log('healthy');
|
|
150
|
+
break;
|
|
151
|
+
}
|
|
152
|
+
await new Promise((r) => setTimeout(r, 2000));
|
|
153
|
+
}
|
|
154
|
+
if (Date.now() >= deadline) {
|
|
155
|
+
console.log('TIMEOUT');
|
|
156
|
+
throw new Error(`Health check timed out for ${svc.name} at ${svc.healthUrl}`);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
console.log('');
|
|
160
|
+
}
|
|
161
|
+
function writeManifest(services) {
|
|
162
|
+
const manifest = { serviceLogs: services.map((s) => s.logPath) };
|
|
163
|
+
fs_1.default.writeFileSync(paths_1.MANIFEST_PATH, JSON.stringify(manifest, null, 2) + '\n');
|
|
164
|
+
}
|
|
165
|
+
// ─── Restart unhealthy services ─────────────────────────────────────────────
|
|
166
|
+
function readPid(safeName) {
|
|
167
|
+
const pidPath = path_1.default.join(paths_1.PIDS_DIR, `${safeName}.pid`);
|
|
168
|
+
if (!fs_1.default.existsSync(pidPath))
|
|
169
|
+
return null;
|
|
170
|
+
const pid = parseInt(fs_1.default.readFileSync(pidPath, 'utf-8').trim(), 10);
|
|
171
|
+
return isNaN(pid) ? null : pid;
|
|
172
|
+
}
|
|
173
|
+
function portFromHealthUrl(url) {
|
|
174
|
+
try {
|
|
175
|
+
const parsed = new URL(url);
|
|
176
|
+
if (parsed.port) {
|
|
177
|
+
return parseInt(parsed.port, 10);
|
|
178
|
+
}
|
|
179
|
+
return parsed.protocol === 'https:' ? 443 : 80;
|
|
180
|
+
}
|
|
181
|
+
catch {
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
function lookupPidByPort(port) {
|
|
186
|
+
try {
|
|
187
|
+
const output = (0, child_process_1.execFileSync)('lsof', ['-ti', `tcp:${port}`, '-sTCP:LISTEN'], {
|
|
188
|
+
encoding: 'utf-8',
|
|
189
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
190
|
+
}).trim();
|
|
191
|
+
const pid = parseInt(output.split('\n')[0]?.trim() ?? '', 10);
|
|
192
|
+
return Number.isNaN(pid) ? null : pid;
|
|
193
|
+
}
|
|
194
|
+
catch {
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
function isProcessAlive(pid) {
|
|
199
|
+
try {
|
|
200
|
+
process.kill(pid, 0);
|
|
201
|
+
return true;
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
return false;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
function killProcessSync(pid) {
|
|
208
|
+
try {
|
|
209
|
+
process.kill(pid, 'SIGTERM');
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
// Best-effort sync kill for signal handlers — send SIGKILL after SIGTERM
|
|
215
|
+
try {
|
|
216
|
+
process.kill(pid, 'SIGKILL');
|
|
217
|
+
}
|
|
218
|
+
catch {
|
|
219
|
+
// ignore
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
async function killProcess(pid) {
|
|
223
|
+
try {
|
|
224
|
+
process.kill(pid, 'SIGTERM');
|
|
225
|
+
}
|
|
226
|
+
catch {
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
// Wait up to 5s for graceful shutdown
|
|
230
|
+
const deadline = Date.now() + 5000;
|
|
231
|
+
while (Date.now() < deadline && isProcessAlive(pid)) {
|
|
232
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
233
|
+
}
|
|
234
|
+
if (isProcessAlive(pid)) {
|
|
235
|
+
try {
|
|
236
|
+
process.kill(pid, 'SIGKILL');
|
|
237
|
+
}
|
|
238
|
+
catch {
|
|
239
|
+
// ignore
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
function resolveRunningPid(svc) {
|
|
244
|
+
const pidFromFile = readPid(svc.safeName);
|
|
245
|
+
if (pidFromFile && isProcessAlive(pidFromFile)) {
|
|
246
|
+
return pidFromFile;
|
|
247
|
+
}
|
|
248
|
+
if (!svc.healthUrl) {
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
251
|
+
const port = portFromHealthUrl(svc.healthUrl);
|
|
252
|
+
if (!port) {
|
|
253
|
+
return null;
|
|
254
|
+
}
|
|
255
|
+
const pidFromPort = lookupPidByPort(port);
|
|
256
|
+
if (pidFromPort && isProcessAlive(pidFromPort)) {
|
|
257
|
+
return pidFromPort;
|
|
258
|
+
}
|
|
259
|
+
return null;
|
|
260
|
+
}
|
|
261
|
+
async function restartAllServices(services, terminal) {
|
|
262
|
+
console.log('\n Restarting all configured services...');
|
|
263
|
+
// Kill all running services first
|
|
264
|
+
for (const svc of services) {
|
|
265
|
+
process.stdout.write(` ${svc.name}: `);
|
|
266
|
+
const pid = resolveRunningPid(svc);
|
|
267
|
+
if (pid) {
|
|
268
|
+
process.stdout.write(`stopping PID ${pid}... `);
|
|
269
|
+
await killProcess(pid);
|
|
270
|
+
console.log('stopped');
|
|
271
|
+
}
|
|
272
|
+
else {
|
|
273
|
+
console.log('no existing process found');
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
// Re-launch all in terminal tabs with tee
|
|
277
|
+
const tabs = services.map((svc) => ({
|
|
278
|
+
dir: svc.cwd,
|
|
279
|
+
command: buildTeedCommand(svc),
|
|
280
|
+
name: svc.name,
|
|
281
|
+
}));
|
|
282
|
+
openTabs(terminal, tabs, ` Re-opening ${terminal} tabs for ${tabs.length} service(s)...`);
|
|
283
|
+
await pollHealthChecks(services);
|
|
284
|
+
}
|
|
285
|
+
// ─── Playwright ─────────────────────────────────────────────────────────────
|
|
286
|
+
const RUN_TIMEOUT = 10 * 60 * 1000; // 10 minutes — safety net for hung runs
|
|
287
|
+
function runPlaywright(featureDir, headed) {
|
|
288
|
+
return new Promise((resolve, reject) => {
|
|
289
|
+
const playwrightArgs = [
|
|
290
|
+
'playwright',
|
|
291
|
+
'test',
|
|
292
|
+
`--reporter=${SUMMARY_REPORTER},list`,
|
|
293
|
+
...(headed ? ['--headed'] : []),
|
|
294
|
+
];
|
|
295
|
+
const child = (0, child_process_1.spawn)('npx', playwrightArgs, {
|
|
296
|
+
cwd: featureDir,
|
|
297
|
+
env: {
|
|
298
|
+
...process.env,
|
|
299
|
+
CANARY_LAB_PROJECT_ROOT: paths_1.ROOT,
|
|
300
|
+
},
|
|
301
|
+
stdio: 'inherit',
|
|
302
|
+
shell: false,
|
|
303
|
+
});
|
|
304
|
+
const timer = setTimeout(() => {
|
|
305
|
+
console.log('\n Playwright run timed out after 10 minutes, killing...');
|
|
306
|
+
child.kill('SIGTERM');
|
|
307
|
+
setTimeout(() => {
|
|
308
|
+
try {
|
|
309
|
+
child.kill('SIGKILL');
|
|
310
|
+
}
|
|
311
|
+
catch {
|
|
312
|
+
// already dead
|
|
313
|
+
}
|
|
314
|
+
}, 5000);
|
|
315
|
+
}, RUN_TIMEOUT);
|
|
316
|
+
const forwardSigInt = () => child.kill('SIGINT');
|
|
317
|
+
const forwardSigTerm = () => child.kill('SIGTERM');
|
|
318
|
+
process.on('SIGINT', forwardSigInt);
|
|
319
|
+
process.on('SIGTERM', forwardSigTerm);
|
|
320
|
+
child.on('error', (err) => {
|
|
321
|
+
clearTimeout(timer);
|
|
322
|
+
process.off('SIGINT', forwardSigInt);
|
|
323
|
+
process.off('SIGTERM', forwardSigTerm);
|
|
324
|
+
reject(err);
|
|
325
|
+
});
|
|
326
|
+
child.on('exit', (code) => {
|
|
327
|
+
clearTimeout(timer);
|
|
328
|
+
process.off('SIGINT', forwardSigInt);
|
|
329
|
+
process.off('SIGTERM', forwardSigTerm);
|
|
330
|
+
resolve(code ?? 1);
|
|
331
|
+
});
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
// ─── Log enrichment ─────────────────────────────────────────────────────────
|
|
335
|
+
function extractLogsForTest(slug, serviceLogs) {
|
|
336
|
+
const logs = {};
|
|
337
|
+
const openTag = `<${slug}>`;
|
|
338
|
+
const closeTag = `</${slug}>`;
|
|
339
|
+
for (const logPath of serviceLogs) {
|
|
340
|
+
if (!fs_1.default.existsSync(logPath))
|
|
341
|
+
continue;
|
|
342
|
+
const content = fs_1.default.readFileSync(logPath, 'utf-8');
|
|
343
|
+
const openIdx = content.indexOf(openTag);
|
|
344
|
+
const closeIdx = content.indexOf(closeTag);
|
|
345
|
+
if (openIdx === -1 || closeIdx === -1 || closeIdx <= openIdx)
|
|
346
|
+
continue;
|
|
347
|
+
const snippet = content
|
|
348
|
+
.slice(openIdx + openTag.length, closeIdx)
|
|
349
|
+
.trim();
|
|
350
|
+
if (snippet.length > 0) {
|
|
351
|
+
const svcName = path_1.default.basename(logPath, '.log');
|
|
352
|
+
logs[svcName] = snippet;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
return logs;
|
|
356
|
+
}
|
|
357
|
+
function enrichSummaryWithLogs() {
|
|
358
|
+
if (!fs_1.default.existsSync(paths_1.SUMMARY_PATH) || !fs_1.default.existsSync(paths_1.MANIFEST_PATH))
|
|
359
|
+
return;
|
|
360
|
+
const summary = JSON.parse(fs_1.default.readFileSync(paths_1.SUMMARY_PATH, 'utf-8'));
|
|
361
|
+
const manifest = JSON.parse(fs_1.default.readFileSync(paths_1.MANIFEST_PATH, 'utf-8'));
|
|
362
|
+
if (!Array.isArray(summary.failed) || summary.failed.length === 0)
|
|
363
|
+
return;
|
|
364
|
+
// Enrich: replace string slugs with {name, logs} objects
|
|
365
|
+
summary.failed = summary.failed.map((entry) => {
|
|
366
|
+
const slug = typeof entry === 'string' ? entry : entry.name;
|
|
367
|
+
const logs = extractLogsForTest(slug, manifest.serviceLogs);
|
|
368
|
+
return { name: slug, logs };
|
|
369
|
+
});
|
|
370
|
+
fs_1.default.writeFileSync(paths_1.SUMMARY_PATH, JSON.stringify(summary, null, 2) + '\n');
|
|
371
|
+
}
|
|
372
|
+
// ─── Summary ────────────────────────────────────────────────────────────────
|
|
373
|
+
function printSummary() {
|
|
374
|
+
if (!fs_1.default.existsSync(paths_1.SUMMARY_PATH)) {
|
|
375
|
+
console.log('\n No summary file found.');
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
const summary = JSON.parse(fs_1.default.readFileSync(paths_1.SUMMARY_PATH, 'utf-8'));
|
|
379
|
+
console.log('\n ──── E2E Summary ────');
|
|
380
|
+
console.log(` Total: ${summary.total}`);
|
|
381
|
+
console.log(` Passed: ${summary.passed}`);
|
|
382
|
+
console.log(` Failed: ${summary.failed.length}`);
|
|
383
|
+
if (summary.failed.length > 0) {
|
|
384
|
+
console.log(` Failures:`);
|
|
385
|
+
for (const entry of summary.failed) {
|
|
386
|
+
const name = typeof entry === 'string' ? entry : entry.name;
|
|
387
|
+
console.log(` - ${name}`);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
console.log('');
|
|
391
|
+
}
|
|
392
|
+
// ─── Watch mode ─────────────────────────────────────────────────────────────
|
|
393
|
+
async function watchMode(services, featureDir, headed, terminal) {
|
|
394
|
+
// Clean any stale signal files
|
|
395
|
+
try {
|
|
396
|
+
fs_1.default.unlinkSync(paths_1.RERUN_SIGNAL);
|
|
397
|
+
}
|
|
398
|
+
catch { /* ignore */ }
|
|
399
|
+
try {
|
|
400
|
+
fs_1.default.unlinkSync(paths_1.RESTART_SIGNAL);
|
|
401
|
+
}
|
|
402
|
+
catch { /* ignore */ }
|
|
403
|
+
console.log(' ──── Watch Mode ────');
|
|
404
|
+
console.log(' Waiting for signal...');
|
|
405
|
+
console.log(' touch logs/.rerun — re-run tests');
|
|
406
|
+
console.log(' touch logs/.restart — restart services + re-run');
|
|
407
|
+
console.log(' Ctrl+C — stop everything');
|
|
408
|
+
console.log('');
|
|
409
|
+
while (true) {
|
|
410
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
411
|
+
const doRestart = fs_1.default.existsSync(paths_1.RESTART_SIGNAL);
|
|
412
|
+
const doRerun = fs_1.default.existsSync(paths_1.RERUN_SIGNAL);
|
|
413
|
+
if (!doRestart && !doRerun)
|
|
414
|
+
continue;
|
|
415
|
+
// Consume signal files
|
|
416
|
+
try {
|
|
417
|
+
fs_1.default.unlinkSync(paths_1.RESTART_SIGNAL);
|
|
418
|
+
}
|
|
419
|
+
catch { /* ignore */ }
|
|
420
|
+
try {
|
|
421
|
+
fs_1.default.unlinkSync(paths_1.RERUN_SIGNAL);
|
|
422
|
+
}
|
|
423
|
+
catch { /* ignore */ }
|
|
424
|
+
if (doRestart) {
|
|
425
|
+
await restartAllServices(services, terminal);
|
|
426
|
+
}
|
|
427
|
+
console.log(`\n Re-running Playwright tests${headed ? ' (headed)' : ''}...\n`);
|
|
428
|
+
await runPlaywright(featureDir, headed);
|
|
429
|
+
enrichSummaryWithLogs();
|
|
430
|
+
printSummary();
|
|
431
|
+
console.log(' ──── Watch Mode ────');
|
|
432
|
+
console.log(' Waiting for signal...\n');
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
// ─── Main ───────────────────────────────────────────────────────────────────
|
|
436
|
+
async function main() {
|
|
437
|
+
const rl = readline_1.default.createInterface({
|
|
438
|
+
input: process.stdin,
|
|
439
|
+
output: process.stdout,
|
|
440
|
+
});
|
|
441
|
+
let envSetApplied = false;
|
|
442
|
+
let appliedFeatureDir = '';
|
|
443
|
+
let cleanedUp = false;
|
|
444
|
+
let services = [];
|
|
445
|
+
// Cleanup stops all launched services and reverts env sets.
|
|
446
|
+
const cleanup = () => {
|
|
447
|
+
if (cleanedUp)
|
|
448
|
+
return;
|
|
449
|
+
cleanedUp = true;
|
|
450
|
+
// Stop service processes
|
|
451
|
+
if (services.length > 0) {
|
|
452
|
+
console.log('\n Stopping services...');
|
|
453
|
+
for (const svc of services) {
|
|
454
|
+
const pid = resolveRunningPid(svc);
|
|
455
|
+
if (pid) {
|
|
456
|
+
process.stdout.write(` ${svc.name}: stopping PID ${pid}... `);
|
|
457
|
+
killProcessSync(pid);
|
|
458
|
+
console.log('stopped');
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
if (envSetApplied) {
|
|
463
|
+
console.log('\n Reverting env files...');
|
|
464
|
+
try {
|
|
465
|
+
(0, child_process_1.execFileSync)(process.execPath, [SWITCH_SCRIPT, appliedFeatureDir, '--revert'], {
|
|
466
|
+
stdio: 'inherit',
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
catch {
|
|
470
|
+
console.error(' Warning: env revert failed. Run `yarn env:revert` manually.');
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
};
|
|
474
|
+
process.on('SIGINT', () => {
|
|
475
|
+
cleanup();
|
|
476
|
+
process.exit(130);
|
|
477
|
+
});
|
|
478
|
+
process.on('SIGTERM', () => {
|
|
479
|
+
cleanup();
|
|
480
|
+
process.exit(143);
|
|
481
|
+
});
|
|
482
|
+
try {
|
|
483
|
+
console.log('\n Canary Lab — E2E Runner\n');
|
|
484
|
+
// ── 1. Discover features
|
|
485
|
+
const features = discoverFeatures();
|
|
486
|
+
if (features.length === 0) {
|
|
487
|
+
console.error('No features found. Add a feature.config.cjs to a features/<name>/ folder.');
|
|
488
|
+
process.exit(1);
|
|
489
|
+
}
|
|
490
|
+
// ── 2. Select feature
|
|
491
|
+
const labels = features.map((f) => `${f.name} — ${f.description}`);
|
|
492
|
+
const chosen = await selectOption(rl, 'Which feature?', labels);
|
|
493
|
+
const feature = features[labels.indexOf(chosen)];
|
|
494
|
+
// ── 3. Select environment
|
|
495
|
+
let env;
|
|
496
|
+
if (feature.envs.length === 1) {
|
|
497
|
+
env = feature.envs[0];
|
|
498
|
+
console.log(`\n Environment: ${env}`);
|
|
499
|
+
}
|
|
500
|
+
else {
|
|
501
|
+
env = await selectOption(rl, 'Which environment?', feature.envs);
|
|
502
|
+
}
|
|
503
|
+
// ── 4. Select terminal
|
|
504
|
+
const terminalChoice = await selectOption(rl, 'Which terminal?', ['iTerm', 'Terminal']);
|
|
505
|
+
// ── 5. Headed?
|
|
506
|
+
const headedChoice = await selectOption(rl, 'Run headed (browser visible)?', ['No (headless)', 'Yes (headed)']);
|
|
507
|
+
const headed = headedChoice.startsWith('Yes');
|
|
508
|
+
// ── 6. Check repos
|
|
509
|
+
console.log('\n Checking prerequisites...');
|
|
510
|
+
if (!checkRepos(feature)) {
|
|
511
|
+
console.error('\n Please clone the missing repos and try again.');
|
|
512
|
+
process.exit(1);
|
|
513
|
+
}
|
|
514
|
+
console.log(' All repos present.');
|
|
515
|
+
// ── 7. Apply env sets
|
|
516
|
+
const envSetsDir = path_1.default.join(feature.featureDir, 'envsets');
|
|
517
|
+
if (fs_1.default.existsSync(path_1.default.join(envSetsDir, 'envsets.config.json'))) {
|
|
518
|
+
const envSets = fs_1.default
|
|
519
|
+
.readdirSync(envSetsDir, { withFileTypes: true })
|
|
520
|
+
.filter((d) => d.isDirectory())
|
|
521
|
+
.map((d) => d.name)
|
|
522
|
+
.sort();
|
|
523
|
+
let chosenSet;
|
|
524
|
+
if (envSets.includes(env)) {
|
|
525
|
+
chosenSet = env;
|
|
526
|
+
}
|
|
527
|
+
else if (envSets.length === 1) {
|
|
528
|
+
chosenSet = envSets[0];
|
|
529
|
+
}
|
|
530
|
+
else {
|
|
531
|
+
chosenSet = await selectOption(rl, `Which env set for ${feature.name}?`, envSets);
|
|
532
|
+
}
|
|
533
|
+
console.log(`\n Applying env set: ${chosenSet}`);
|
|
534
|
+
(0, child_process_1.execFileSync)(process.execPath, [SWITCH_SCRIPT, feature.featureDir, '--apply', chosenSet], { stdio: 'inherit' });
|
|
535
|
+
envSetApplied = true;
|
|
536
|
+
appliedFeatureDir = feature.featureDir;
|
|
537
|
+
}
|
|
538
|
+
rl.close();
|
|
539
|
+
// ── 8. Prepare logs directory
|
|
540
|
+
fs_1.default.rmSync(paths_1.LOGS_DIR, { recursive: true, force: true });
|
|
541
|
+
fs_1.default.mkdirSync(paths_1.PIDS_DIR, { recursive: true });
|
|
542
|
+
// ── 9. Build service list and launch in terminal tabs (with tee to log files)
|
|
543
|
+
services = buildServiceList(feature);
|
|
544
|
+
await launchServices(services, terminalChoice);
|
|
545
|
+
writeManifest(services);
|
|
546
|
+
// ── 10. Wait for health checks
|
|
547
|
+
await pollHealthChecks(services);
|
|
548
|
+
// ── 11. Run Playwright
|
|
549
|
+
console.log(` Running Playwright tests${headed ? ' (headed)' : ''}...\n`);
|
|
550
|
+
await runPlaywright(feature.featureDir, headed);
|
|
551
|
+
// ── 12. Enrich summary with log snippets and print
|
|
552
|
+
enrichSummaryWithLogs();
|
|
553
|
+
printSummary();
|
|
554
|
+
// ── 13. Enter watch mode (loops forever until Ctrl+C)
|
|
555
|
+
await watchMode(services, feature.featureDir, headed, terminalChoice);
|
|
556
|
+
}
|
|
557
|
+
catch (err) {
|
|
558
|
+
cleanup();
|
|
559
|
+
throw err;
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
if (require.main === module) {
|
|
563
|
+
main().catch((err) => {
|
|
564
|
+
console.error(err);
|
|
565
|
+
process.exit(1);
|
|
566
|
+
});
|
|
567
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { FullResult, Reporter, TestCase, TestResult } from '@playwright/test/reporter';
|
|
2
|
+
declare class SummaryReporter implements Reporter {
|
|
3
|
+
private results;
|
|
4
|
+
onTestEnd(test: TestCase, result: TestResult): void;
|
|
5
|
+
onEnd(_result: FullResult): void;
|
|
6
|
+
}
|
|
7
|
+
export default SummaryReporter;
|