@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 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 generate blob reports into `--output` directory. To merge it use [Playwright's Merge-reports CLI](https://playwright.dev/docs/test-sharding#merge-reports-cli)
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
- **`webServer` property is not supported and is ignored; make sure the app run beforehand.**
147
+ #### Setup Phase
148
148
 
149
- | Option | Description | Type | Default | Required? |
150
- | -------------- | ----------------------------------------- | -------- | -------------- | --------- |
151
- | `--run-id` | Run ID generated by `create` command | `string` | - | yes |
152
- | `-o, --output` | Directory for artifacts produced by tests | `string` | `blob_reports` | no |
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,2 @@
1
+ declare const _default: () => Promise<void>;
2
+ export default _default;
@@ -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
+ };
@@ -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();
@@ -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 runner = new TestRunner(options, adapter);
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,
@@ -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
- constructor(options: {
8
- runId: string;
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;
@@ -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
- next = await this.adapter.getNextTest(this.runId, config);
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 webServer, clear project dependencies, add our reporters
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 - not supported in orchestrator (server should already be running)
166
+ // Remove webServer - managed by orchestrator's SetupManager
138
167
  delete config.webServer;
139
168
 
140
- // Clear project dependencies - setup projects should be run separately before orchestrator
141
- // Each orchestrator test runs in a fresh playwright process, and we don't want
142
- // setup projects re-running for every test
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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dionlarson/playwright-orchestrator-core",
3
- "version": "1.3.9",
3
+ "version": "1.5.0",
4
4
  "description": "Core lib and cli for Playwright test orchestration",
5
5
  "keywords": [
6
6
  "playwright",