@dionlarson/playwright-orchestrator-core 1.3.9 → 1.5.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/README.md +19 -6
- package/dist/adapter.d.ts +1 -0
- package/dist/commands/cleanup.d.ts +2 -0
- package/dist/commands/cleanup.js +17 -0
- package/dist/commands/program.js +2 -0
- package/dist/commands/run.js +11 -1
- package/dist/helpers/setup-manager.d.ts +37 -0
- package/dist/helpers/setup-manager.js +182 -0
- package/dist/playwright-tools/run-builder.d.ts +3 -0
- package/dist/playwright-tools/run-builder.js +36 -0
- package/dist/test-runner.d.ts +10 -4
- package/dist/test-runner.js +39 -6
- package/dist/types/test-info.d.ts +24 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -142,14 +142,27 @@ Creates and configures a new test run. Outputs created run ID. Supports most of
|
|
|
142
142
|
|
|
143
143
|
Starts a test shard for the provided test run. If used with a finished run, it will only start failed tests.
|
|
144
144
|
|
|
145
|
-
Command
|
|
145
|
+
Command generates blob reports into `--output` directory. To merge them use [Playwright's Merge-reports CLI](https://playwright.dev/docs/test-sharding#merge-reports-cli)
|
|
146
146
|
|
|
147
|
-
|
|
147
|
+
#### Setup Phase
|
|
148
148
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
149
|
+
Each shard automatically runs these setup operations **once** before executing tests:
|
|
150
|
+
|
|
151
|
+
1. **globalSetup** - Runs your `globalSetup` file (if configured in playwright.config.ts)
|
|
152
|
+
2. **webServer** - Starts your `webServer` (if configured) and waits for it to be ready
|
|
153
|
+
3. **Setup projects** - Runs any projects that are dependencies but have no dependencies themselves
|
|
154
|
+
|
|
155
|
+
After all tests complete (or on failure), teardown runs in reverse order: stop webServer → run globalTeardown.
|
|
156
|
+
|
|
157
|
+
**Note:** Each shard is independent (runs on different machines), so setup runs once per shard, not once globally.
|
|
158
|
+
|
|
159
|
+
| Option | Description | Type | Default | Required? |
|
|
160
|
+
| ------------------------ | -------------------------------------------------- | -------- | -------------- | --------- |
|
|
161
|
+
| `--run-id` | Run ID generated by `create` command | `string` | - | yes |
|
|
162
|
+
| `-o, --output` | Directory for artifacts produced by tests | `string` | `blob_reports` | no |
|
|
163
|
+
| `--skip-global-setup` | Skip globalSetup (use when managed externally) | - | - | no |
|
|
164
|
+
| `--skip-web-server` | Skip webServer startup (use when already running) | - | - | no |
|
|
165
|
+
| `--skip-setup-projects` | Skip setup project execution | - | - | no |
|
|
153
166
|
|
|
154
167
|
### `create-report`
|
|
155
168
|
|
package/dist/adapter.d.ts
CHANGED
|
@@ -11,6 +11,7 @@ export declare abstract class Adapter {
|
|
|
11
11
|
abstract finishShard(runId: string): Promise<void>;
|
|
12
12
|
abstract getReportData(runId: string): Promise<TestRunReport>;
|
|
13
13
|
abstract dispose(): Promise<void>;
|
|
14
|
+
abstract cleanupStaleTests(runId: string, staleMinutes: number): Promise<number>;
|
|
14
15
|
protected transformTestRunToItems(run: TestRun): ReporterTestItem[];
|
|
15
16
|
protected sortTests(tests: ReporterTestItem[], testInfoMap: Map<string, TestSortItem>, { historyWindow, reverse }: SortTestsOptions): ReporterTestItem[];
|
|
16
17
|
private extractCompareValue;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { loadPlugins } from '../helpers/plugin.js';
|
|
2
|
+
import { withErrorHandling } from './error-handler.js';
|
|
3
|
+
import { program } from './program.js';
|
|
4
|
+
export default async () => {
|
|
5
|
+
const command = program.command('cleanup').description('Reset stale tests for a run');
|
|
6
|
+
for await (const { factory, subCommand } of loadPlugins(command)) {
|
|
7
|
+
subCommand
|
|
8
|
+
.requiredOption('--run-id <string>', 'Run id to cleanup')
|
|
9
|
+
.option('--stale-minutes <number>', 'Consider tests stale after N minutes', '10')
|
|
10
|
+
.action(withErrorHandling(async (options) => {
|
|
11
|
+
const adapter = await factory(options);
|
|
12
|
+
const count = await adapter.cleanupStaleTests(options.runId, parseInt(options.staleMinutes));
|
|
13
|
+
console.log(`Reset ${count} stale tests`);
|
|
14
|
+
await adapter.dispose();
|
|
15
|
+
}));
|
|
16
|
+
}
|
|
17
|
+
};
|
package/dist/commands/program.js
CHANGED
|
@@ -3,6 +3,7 @@ import init from './init.js';
|
|
|
3
3
|
import run from './run.js';
|
|
4
4
|
import create from './create.js';
|
|
5
5
|
import createReport from './create-report.js';
|
|
6
|
+
import cleanup from './cleanup.js';
|
|
6
7
|
import { readFile } from 'node:fs/promises';
|
|
7
8
|
import { fileURLToPath } from 'node:url';
|
|
8
9
|
import { dirname, join } from 'node:path';
|
|
@@ -17,4 +18,5 @@ await init();
|
|
|
17
18
|
await run();
|
|
18
19
|
await create();
|
|
19
20
|
await createReport();
|
|
21
|
+
await cleanup();
|
|
20
22
|
program.parse();
|
package/dist/commands/run.js
CHANGED
|
@@ -8,9 +8,19 @@ export default async () => {
|
|
|
8
8
|
subCommand
|
|
9
9
|
.requiredOption('--run-id <string>', 'Run id generated by create command')
|
|
10
10
|
.option('-o, --output <string>', 'Output folder for blob reports. Existing content is deleted before writing the new report.', 'blob-reports')
|
|
11
|
+
.option('--skip-global-setup', 'Skip globalSetup (use when managed externally)')
|
|
12
|
+
.option('--skip-web-server', 'Skip webServer startup (use when server already running)')
|
|
13
|
+
.option('--skip-setup-projects', 'Skip setup project execution')
|
|
11
14
|
.action(withErrorHandling(async (options) => {
|
|
12
15
|
const adapter = await factory(options);
|
|
13
|
-
const
|
|
16
|
+
const runnerOptions = {
|
|
17
|
+
runId: options.runId,
|
|
18
|
+
output: options.output,
|
|
19
|
+
skipGlobalSetup: options.skipGlobalSetup,
|
|
20
|
+
skipWebServer: options.skipWebServer,
|
|
21
|
+
skipSetupProjects: options.skipSetupProjects,
|
|
22
|
+
};
|
|
23
|
+
const runner = new TestRunner(runnerOptions, adapter);
|
|
14
24
|
await runner.runTests();
|
|
15
25
|
console.log('Run completed');
|
|
16
26
|
}));
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { SetupConfig } from '../types/test-info.js';
|
|
2
|
+
export interface SetupManagerOptions {
|
|
3
|
+
configFile?: string;
|
|
4
|
+
skipGlobalSetup?: boolean;
|
|
5
|
+
skipWebServer?: boolean;
|
|
6
|
+
skipSetupProjects?: boolean;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Manages shard-level setup and teardown.
|
|
10
|
+
* Each shard runs independently on potentially different machines,
|
|
11
|
+
* so setup runs once per shard (not once globally).
|
|
12
|
+
*/
|
|
13
|
+
export declare class SetupManager {
|
|
14
|
+
private readonly setup;
|
|
15
|
+
private readonly options;
|
|
16
|
+
private webServerProcesses;
|
|
17
|
+
private setupComplete;
|
|
18
|
+
constructor(setup: SetupConfig, options?: SetupManagerOptions);
|
|
19
|
+
/**
|
|
20
|
+
* Run all setup operations in order: globalSetup → webServers → setupProjects
|
|
21
|
+
*/
|
|
22
|
+
runSetup(): Promise<void>;
|
|
23
|
+
/**
|
|
24
|
+
* Run teardown operations: stop webServers → globalTeardown
|
|
25
|
+
* Always runs in finally block, even on test failures.
|
|
26
|
+
*/
|
|
27
|
+
runTeardown(): Promise<void>;
|
|
28
|
+
private runGlobalSetup;
|
|
29
|
+
private runGlobalTeardown;
|
|
30
|
+
private startWebServers;
|
|
31
|
+
private startWebServer;
|
|
32
|
+
private waitForServer;
|
|
33
|
+
private stopWebServers;
|
|
34
|
+
private runSetupProjects;
|
|
35
|
+
private execCommand;
|
|
36
|
+
private buildConfigForHooks;
|
|
37
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
/**
|
|
4
|
+
* Manages shard-level setup and teardown.
|
|
5
|
+
* Each shard runs independently on potentially different machines,
|
|
6
|
+
* so setup runs once per shard (not once globally).
|
|
7
|
+
*/
|
|
8
|
+
export class SetupManager {
|
|
9
|
+
setup;
|
|
10
|
+
options;
|
|
11
|
+
webServerProcesses = [];
|
|
12
|
+
setupComplete = false;
|
|
13
|
+
constructor(setup, options = {}) {
|
|
14
|
+
this.setup = setup;
|
|
15
|
+
this.options = options;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Run all setup operations in order: globalSetup → webServers → setupProjects
|
|
19
|
+
*/
|
|
20
|
+
async runSetup() {
|
|
21
|
+
console.log('[orchestrator:setup] Starting shard setup...');
|
|
22
|
+
try {
|
|
23
|
+
await this.runGlobalSetup();
|
|
24
|
+
await this.startWebServers();
|
|
25
|
+
await this.runSetupProjects();
|
|
26
|
+
this.setupComplete = true;
|
|
27
|
+
console.log('[orchestrator:setup] Shard setup complete');
|
|
28
|
+
}
|
|
29
|
+
catch (error) {
|
|
30
|
+
console.error('[orchestrator:setup] Setup failed:', error);
|
|
31
|
+
// Clean up any started web servers before re-throwing
|
|
32
|
+
await this.stopWebServers();
|
|
33
|
+
throw error;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Run teardown operations: stop webServers → globalTeardown
|
|
38
|
+
* Always runs in finally block, even on test failures.
|
|
39
|
+
*/
|
|
40
|
+
async runTeardown() {
|
|
41
|
+
console.log('[orchestrator:teardown] Starting shard teardown...');
|
|
42
|
+
await this.stopWebServers();
|
|
43
|
+
// Only run globalTeardown if setup completed successfully
|
|
44
|
+
if (this.setupComplete) {
|
|
45
|
+
await this.runGlobalTeardown();
|
|
46
|
+
}
|
|
47
|
+
console.log('[orchestrator:teardown] Shard teardown complete');
|
|
48
|
+
}
|
|
49
|
+
async runGlobalSetup() {
|
|
50
|
+
if (this.options.skipGlobalSetup || !this.setup.globalSetup) {
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
console.log(`[orchestrator:setup] Running globalSetup: ${this.setup.globalSetup}`);
|
|
54
|
+
const setupPath = path.resolve(this.setup.globalSetup);
|
|
55
|
+
// Dynamic import handles ESM/CJS/TS via Node's loader
|
|
56
|
+
const setupModule = await import(setupPath);
|
|
57
|
+
const setupFn = setupModule.default || setupModule;
|
|
58
|
+
if (typeof setupFn === 'function') {
|
|
59
|
+
// Pass minimal config object matching Playwright's FullConfig shape
|
|
60
|
+
const config = this.buildConfigForHooks();
|
|
61
|
+
await setupFn(config);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
async runGlobalTeardown() {
|
|
65
|
+
if (!this.setup.globalTeardown) {
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
console.log(`[orchestrator:teardown] Running globalTeardown: ${this.setup.globalTeardown}`);
|
|
69
|
+
const teardownPath = path.resolve(this.setup.globalTeardown);
|
|
70
|
+
const teardownModule = await import(teardownPath);
|
|
71
|
+
const teardownFn = teardownModule.default || teardownModule;
|
|
72
|
+
if (typeof teardownFn === 'function') {
|
|
73
|
+
const config = this.buildConfigForHooks();
|
|
74
|
+
await teardownFn(config);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
async startWebServers() {
|
|
78
|
+
if (this.options.skipWebServer || this.setup.webServers.length === 0) {
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
console.log(`[orchestrator:setup] Starting ${this.setup.webServers.length} webServer(s)...`);
|
|
82
|
+
for (const server of this.setup.webServers) {
|
|
83
|
+
await this.startWebServer(server);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
async startWebServer(server) {
|
|
87
|
+
const name = server.url || `port ${server.port}`;
|
|
88
|
+
console.log(`[orchestrator:setup] Starting webServer: ${server.command}`);
|
|
89
|
+
const proc = spawn(server.command, {
|
|
90
|
+
shell: true,
|
|
91
|
+
cwd: server.cwd,
|
|
92
|
+
env: { ...process.env, ...server.env },
|
|
93
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
94
|
+
detached: process.platform !== 'win32',
|
|
95
|
+
});
|
|
96
|
+
this.webServerProcesses.push(proc);
|
|
97
|
+
// Log stderr for debugging
|
|
98
|
+
proc.stderr?.on('data', (data) => {
|
|
99
|
+
console.error(`[webServer:stderr] ${data.toString().trim()}`);
|
|
100
|
+
});
|
|
101
|
+
// Wait for server to be ready
|
|
102
|
+
if (server.url || server.port) {
|
|
103
|
+
const timeout = server.timeout || 60000;
|
|
104
|
+
await this.waitForServer(server, timeout);
|
|
105
|
+
console.log(`[orchestrator:setup] webServer ready at ${name}`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
async waitForServer(server, timeout) {
|
|
109
|
+
const resource = server.url || `http://localhost:${server.port}`;
|
|
110
|
+
const startTime = Date.now();
|
|
111
|
+
// Simple polling loop - check every 500ms
|
|
112
|
+
while (Date.now() - startTime < timeout) {
|
|
113
|
+
try {
|
|
114
|
+
const response = await fetch(resource, { method: 'HEAD' });
|
|
115
|
+
if (response.ok || response.status < 500) {
|
|
116
|
+
return; // Server is ready
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
// Server not ready yet, continue polling
|
|
121
|
+
}
|
|
122
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
123
|
+
}
|
|
124
|
+
throw new Error(`webServer at ${resource} did not become ready within ${timeout}ms`);
|
|
125
|
+
}
|
|
126
|
+
async stopWebServers() {
|
|
127
|
+
if (this.webServerProcesses.length === 0) {
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
console.log(`[orchestrator:teardown] Stopping ${this.webServerProcesses.length} webServer(s)...`);
|
|
131
|
+
for (const proc of this.webServerProcesses) {
|
|
132
|
+
if (proc.pid && !proc.killed) {
|
|
133
|
+
try {
|
|
134
|
+
// Kill the process group on unix, just the process on windows
|
|
135
|
+
if (process.platform !== 'win32') {
|
|
136
|
+
process.kill(-proc.pid, 'SIGTERM');
|
|
137
|
+
}
|
|
138
|
+
else {
|
|
139
|
+
proc.kill('SIGTERM');
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
// Process may have already exited
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
this.webServerProcesses = [];
|
|
148
|
+
}
|
|
149
|
+
async runSetupProjects() {
|
|
150
|
+
if (this.options.skipSetupProjects || this.setup.setupProjects.length === 0) {
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
for (const projectName of this.setup.setupProjects) {
|
|
154
|
+
console.log(`[orchestrator:setup] Running setup project: ${projectName}`);
|
|
155
|
+
const args = ['npx', 'playwright', 'test', '--project', projectName];
|
|
156
|
+
if (this.options.configFile) {
|
|
157
|
+
args.push('--config', this.options.configFile);
|
|
158
|
+
}
|
|
159
|
+
await this.execCommand(args.join(' '));
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
execCommand(command) {
|
|
163
|
+
return new Promise((resolve, reject) => {
|
|
164
|
+
const proc = spawn(command, {
|
|
165
|
+
shell: true,
|
|
166
|
+
stdio: 'inherit',
|
|
167
|
+
});
|
|
168
|
+
proc.on('close', (code) => {
|
|
169
|
+
if (code === 0)
|
|
170
|
+
resolve();
|
|
171
|
+
else
|
|
172
|
+
reject(new Error(`Command failed with code ${code}: ${command}`));
|
|
173
|
+
});
|
|
174
|
+
proc.on('error', reject);
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
buildConfigForHooks() {
|
|
178
|
+
return {
|
|
179
|
+
configFile: this.options.configFile,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
}
|
|
@@ -5,6 +5,9 @@ export declare class RunBuilder {
|
|
|
5
5
|
private config;
|
|
6
6
|
parseEntry(entry: TestCase | Suite): this;
|
|
7
7
|
parseConfig(config: FullConfig): this;
|
|
8
|
+
private extractSetupConfig;
|
|
9
|
+
private extractWebServers;
|
|
10
|
+
private findSetupProjects;
|
|
8
11
|
build(): TestRunInfo;
|
|
9
12
|
private parseSuitesHelper;
|
|
10
13
|
private tryParseEntry;
|
|
@@ -13,9 +13,45 @@ export class RunBuilder {
|
|
|
13
13
|
name: project.name,
|
|
14
14
|
outputDir: project.outputDir,
|
|
15
15
|
})),
|
|
16
|
+
setup: this.extractSetupConfig(config),
|
|
16
17
|
};
|
|
17
18
|
return this;
|
|
18
19
|
}
|
|
20
|
+
extractSetupConfig(config) {
|
|
21
|
+
return {
|
|
22
|
+
globalSetup: config.globalSetup ?? undefined,
|
|
23
|
+
globalTeardown: config.globalTeardown ?? undefined,
|
|
24
|
+
webServers: this.extractWebServers(config.webServer),
|
|
25
|
+
setupProjects: this.findSetupProjects(config.projects),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
extractWebServers(webServer) {
|
|
29
|
+
if (!webServer)
|
|
30
|
+
return [];
|
|
31
|
+
const servers = Array.isArray(webServer) ? webServer : [webServer];
|
|
32
|
+
return servers.map((s) => ({
|
|
33
|
+
command: s.command,
|
|
34
|
+
url: s.url,
|
|
35
|
+
port: s.port,
|
|
36
|
+
timeout: s.timeout,
|
|
37
|
+
reuseExistingServer: s.reuseExistingServer,
|
|
38
|
+
cwd: s.cwd,
|
|
39
|
+
env: s.env,
|
|
40
|
+
}));
|
|
41
|
+
}
|
|
42
|
+
findSetupProjects(projects) {
|
|
43
|
+
// Collect all dependency names
|
|
44
|
+
const allDependencies = new Set();
|
|
45
|
+
for (const project of projects) {
|
|
46
|
+
for (const dep of project.dependencies || []) {
|
|
47
|
+
allDependencies.add(dep);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
// Setup projects = projects that ARE dependencies but have no dependencies themselves
|
|
51
|
+
return projects
|
|
52
|
+
.filter((p) => allDependencies.has(p.name) && (!p.dependencies || p.dependencies.length === 0))
|
|
53
|
+
.map((p) => p.name);
|
|
54
|
+
}
|
|
19
55
|
build() {
|
|
20
56
|
return structuredClone({
|
|
21
57
|
config: this.config,
|
package/dist/test-runner.d.ts
CHANGED
|
@@ -1,13 +1,19 @@
|
|
|
1
1
|
import { Adapter } from './adapter.js';
|
|
2
|
+
export interface TestRunnerOptions {
|
|
3
|
+
runId: string;
|
|
4
|
+
output: string;
|
|
5
|
+
skipGlobalSetup?: boolean;
|
|
6
|
+
skipWebServer?: boolean;
|
|
7
|
+
skipSetupProjects?: boolean;
|
|
8
|
+
}
|
|
2
9
|
export declare class TestRunner {
|
|
10
|
+
private readonly options;
|
|
3
11
|
private readonly adapter;
|
|
4
12
|
private readonly runId;
|
|
5
13
|
private readonly outputFolder;
|
|
6
14
|
private readonly reporter;
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
output: string;
|
|
10
|
-
}, adapter: Adapter);
|
|
15
|
+
private setupManager?;
|
|
16
|
+
constructor(options: TestRunnerOptions, adapter: Adapter);
|
|
11
17
|
runTests(): Promise<void>;
|
|
12
18
|
private runTestsUntilAvailable;
|
|
13
19
|
private removePreviousReports;
|
package/dist/test-runner.js
CHANGED
|
@@ -5,6 +5,7 @@ import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
|
5
5
|
import { TestExecutionReporter } from './reporters/test-execution-reporter.js';
|
|
6
6
|
import path from 'node:path';
|
|
7
7
|
import * as uuid from 'uuid';
|
|
8
|
+
import { SetupManager } from './helpers/setup-manager.js';
|
|
8
9
|
const exec = promisify(child_process.exec);
|
|
9
10
|
function spawnPassthrough(command, env) {
|
|
10
11
|
return new Promise((resolve, reject) => {
|
|
@@ -18,11 +19,14 @@ function spawnPassthrough(command, env) {
|
|
|
18
19
|
});
|
|
19
20
|
}
|
|
20
21
|
export class TestRunner {
|
|
22
|
+
options;
|
|
21
23
|
adapter;
|
|
22
24
|
runId;
|
|
23
25
|
outputFolder;
|
|
24
26
|
reporter = new TestExecutionReporter();
|
|
27
|
+
setupManager;
|
|
25
28
|
constructor(options, adapter) {
|
|
29
|
+
this.options = options;
|
|
26
30
|
this.adapter = adapter;
|
|
27
31
|
this.runId = options.runId;
|
|
28
32
|
this.outputFolder = options.output;
|
|
@@ -32,6 +36,16 @@ export class TestRunner {
|
|
|
32
36
|
await this.removePreviousReports();
|
|
33
37
|
const config = await this.adapter.startShard(this.runId);
|
|
34
38
|
console.log(`[orchestrator] Shard started. Workers: ${config.workers}, Config: ${config.configFile || 'default'}`);
|
|
39
|
+
// Run setup phase (globalSetup → webServers → setup projects)
|
|
40
|
+
if (config.setup) {
|
|
41
|
+
this.setupManager = new SetupManager(config.setup, {
|
|
42
|
+
configFile: config.configFile,
|
|
43
|
+
skipGlobalSetup: this.options.skipGlobalSetup,
|
|
44
|
+
skipWebServer: this.options.skipWebServer,
|
|
45
|
+
skipSetupProjects: this.options.skipSetupProjects,
|
|
46
|
+
});
|
|
47
|
+
await this.setupManager.runSetup();
|
|
48
|
+
}
|
|
35
49
|
config.configFile = await this.createTempConfig(config.configFile);
|
|
36
50
|
try {
|
|
37
51
|
await this.runTestsUntilAvailable(config);
|
|
@@ -43,6 +57,10 @@ export class TestRunner {
|
|
|
43
57
|
finally {
|
|
44
58
|
if (config.configFile)
|
|
45
59
|
await rm(config.configFile);
|
|
60
|
+
// Run teardown phase (stop webServers → globalTeardown)
|
|
61
|
+
if (this.setupManager) {
|
|
62
|
+
await this.setupManager.runTeardown();
|
|
63
|
+
}
|
|
46
64
|
}
|
|
47
65
|
}
|
|
48
66
|
async runTestsUntilAvailable(config) {
|
|
@@ -59,10 +77,21 @@ export class TestRunner {
|
|
|
59
77
|
runningTests.delete(testPromise);
|
|
60
78
|
});
|
|
61
79
|
runningTests.add(testPromise);
|
|
62
|
-
|
|
80
|
+
// Only prefetch if we still have capacity after starting this test
|
|
81
|
+
// This prevents claiming more tests than we can run with workers=1
|
|
82
|
+
if (runningTests.size < config.workers) {
|
|
83
|
+
next = await this.adapter.getNextTest(this.runId, config);
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
next = undefined;
|
|
87
|
+
}
|
|
63
88
|
}
|
|
64
89
|
else {
|
|
65
90
|
await Promise.race(runningTests);
|
|
91
|
+
// Fetch next test after a slot opens up
|
|
92
|
+
if (!next) {
|
|
93
|
+
next = await this.adapter.getNextTest(this.runId, config);
|
|
94
|
+
}
|
|
66
95
|
}
|
|
67
96
|
}
|
|
68
97
|
console.log(`[orchestrator] Finished ${testCount} tests`);
|
|
@@ -130,16 +159,20 @@ export class TestRunner {
|
|
|
130
159
|
async createTempConfig(file) {
|
|
131
160
|
if (!file)
|
|
132
161
|
return;
|
|
133
|
-
// Modify config: remove
|
|
162
|
+
// Modify config: remove setup options (handled by orchestrator), clear dependencies, add reporters
|
|
134
163
|
const content = `
|
|
135
164
|
import config from '${path.resolve(file)}';
|
|
136
165
|
|
|
137
|
-
// Remove webServer -
|
|
166
|
+
// Remove webServer - managed by orchestrator's SetupManager
|
|
138
167
|
delete config.webServer;
|
|
139
168
|
|
|
140
|
-
//
|
|
141
|
-
//
|
|
142
|
-
|
|
169
|
+
// Remove globalSetup/globalTeardown - managed by orchestrator's SetupManager
|
|
170
|
+
// Without this, they would run for EVERY individual test process
|
|
171
|
+
delete config.globalSetup;
|
|
172
|
+
delete config.globalTeardown;
|
|
173
|
+
|
|
174
|
+
// Clear project dependencies - setup projects are run once by orchestrator
|
|
175
|
+
// Each test runs in a fresh playwright process, we don't want setup re-running
|
|
143
176
|
if (config.projects) {
|
|
144
177
|
for (const project of config.projects) {
|
|
145
178
|
project.dependencies = [];
|
|
@@ -3,10 +3,34 @@ export interface Project {
|
|
|
3
3
|
name: string;
|
|
4
4
|
outputDir: string;
|
|
5
5
|
}
|
|
6
|
+
/**
|
|
7
|
+
* WebServer configuration extracted from Playwright config.
|
|
8
|
+
* Mirrors Playwright's WebServerConfig but serializable for storage.
|
|
9
|
+
*/
|
|
10
|
+
export interface WebServerConfig {
|
|
11
|
+
command: string;
|
|
12
|
+
url?: string;
|
|
13
|
+
port?: number;
|
|
14
|
+
timeout?: number;
|
|
15
|
+
reuseExistingServer?: boolean;
|
|
16
|
+
cwd?: string;
|
|
17
|
+
env?: Record<string, string>;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Setup configuration extracted during `create` and used during `run`.
|
|
21
|
+
* Each shard runs setup independently (different machines).
|
|
22
|
+
*/
|
|
23
|
+
export interface SetupConfig {
|
|
24
|
+
globalSetup?: string;
|
|
25
|
+
globalTeardown?: string;
|
|
26
|
+
webServers: WebServerConfig[];
|
|
27
|
+
setupProjects: string[];
|
|
28
|
+
}
|
|
6
29
|
export interface TestConfig {
|
|
7
30
|
workers: number;
|
|
8
31
|
configFile?: string;
|
|
9
32
|
projects: Project[];
|
|
33
|
+
setup?: SetupConfig;
|
|
10
34
|
}
|
|
11
35
|
export interface TestRunConfig extends TestConfig {
|
|
12
36
|
historyWindow: number;
|