@sudobility/testomniac_runner 0.0.160 → 0.0.161

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/bun.lock CHANGED
@@ -8,7 +8,7 @@
8
8
  "@noble/curves": "^1.0.0",
9
9
  "@noble/hashes": "^1.0.0",
10
10
  "@sudobility/signic_sdk": "^0.1.7",
11
- "@sudobility/testomniac_runner_service": "^0.1.154",
11
+ "@sudobility/testomniac_runner_service": "^0.1.155",
12
12
  "@sudobility/testomniac_types": "^0.0.78",
13
13
  "hono": "^4.7.0",
14
14
  "jose": "^6.1.2",
@@ -172,7 +172,7 @@
172
172
 
173
173
  "@sudobility/signic_sdk": ["@sudobility/signic_sdk@0.1.7", "", {}, "sha512-5XSgHSVsmyrMQ/ui1nDywwzt9dbRCsaeJ5tX6mKw2ZXbTZ82OsMr+dqDyV9XV3pfy7IHRIZq73af5KBamx72Fw=="],
174
174
 
175
- "@sudobility/testomniac_runner_service": ["@sudobility/testomniac_runner_service@0.1.154", "", { "peerDependencies": { "@sudobility/testomniac_types": "^0.0.78" } }, "sha512-pHGkl5TcCteE698Y3Z0dGrQjDc43CyN0vdEusPalY9CWiRA4ZbfC8lBEiLwWZsSeIoZLWaQcLEIIdFLjgRDTyg=="],
175
+ "@sudobility/testomniac_runner_service": ["@sudobility/testomniac_runner_service@0.1.155", "", { "peerDependencies": { "@sudobility/testomniac_types": "^0.0.78" } }, "sha512-gu/r8bLxVyu0PQ1LjiJ8BBL4GdA3v3ZmdyG3fyaEeADQUgWyjsZqDwzzMVwdJyPWexORV3R/MxX+gIPIFMZBgQ=="],
176
176
 
177
177
  "@sudobility/testomniac_types": ["@sudobility/testomniac_types@0.0.78", "", { "peerDependencies": { "@sudobility/types": "^1.9.62" } }, "sha512-LMmuSEvrl16v49mRWMwjqSOisc6dwoghZcswaiyj5wRdzT2FHGLIgwl25LvYILaJrWpBy/VOdkOjlBbU/ea2qg=="],
178
178
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sudobility/testomniac_runner",
3
- "version": "0.0.160",
3
+ "version": "0.0.161",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -24,7 +24,7 @@
24
24
  "@noble/curves": "^1.0.0",
25
25
  "@noble/hashes": "^1.0.0",
26
26
  "@sudobility/signic_sdk": "^0.1.7",
27
- "@sudobility/testomniac_runner_service": "^0.1.154",
27
+ "@sudobility/testomniac_runner_service": "^0.1.155",
28
28
  "@sudobility/testomniac_types": "^0.0.78",
29
29
  "hono": "^4.7.0",
30
30
  "jose": "^6.1.2",
@@ -0,0 +1,44 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import { ChromiumManager } from "./chromium";
3
+ import { loadConfig } from "../config/index";
4
+
5
+ beforeEach(() => {
6
+ process.env.SCANNER_API_KEY = "test-key";
7
+ process.env.DEEP_LINK_SECRET = "test-secret";
8
+ });
9
+
10
+ function fakeBrowser() {
11
+ const page = { setViewport: vi.fn().mockResolvedValue(undefined) };
12
+ const context = {
13
+ newPage: vi.fn().mockResolvedValue(page),
14
+ close: vi.fn().mockResolvedValue(undefined),
15
+ };
16
+ const browser = {
17
+ createBrowserContext: vi.fn().mockResolvedValue(context),
18
+ close: vi.fn().mockResolvedValue(undefined),
19
+ };
20
+ return { browser, context, page };
21
+ }
22
+
23
+ describe("ChromiumManager shared browser", () => {
24
+ it("launches the browser only once across multiple launch() calls", async () => {
25
+ const { browser } = fakeBrowser();
26
+ const launcher = vi.fn().mockResolvedValue(browser);
27
+ const mgr = new ChromiumManager(loadConfig(), launcher as never);
28
+ await mgr.launch();
29
+ await mgr.launch();
30
+ expect(launcher).toHaveBeenCalledTimes(1);
31
+ });
32
+
33
+ it("creates an isolated context + page per run and closes only the context", async () => {
34
+ const { browser, context, page } = fakeBrowser();
35
+ const launcher = vi.fn().mockResolvedValue(browser);
36
+ const mgr = new ChromiumManager(loadConfig(), launcher as never);
37
+ await mgr.launch();
38
+ const acquired = await mgr.createContext();
39
+ expect(acquired.page).toBe(page);
40
+ await mgr.closeContext(acquired.context);
41
+ expect(context.close).toHaveBeenCalledTimes(1);
42
+ expect(browser.close).not.toHaveBeenCalled();
43
+ });
44
+ });
@@ -1,10 +1,16 @@
1
1
  import { existsSync, readdirSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
- import puppeteer, { type Browser, type Page } from "puppeteer-core";
4
+ import puppeteer, {
5
+ type Browser,
6
+ type BrowserContext,
7
+ type Page,
8
+ } from "puppeteer-core";
5
9
  import type { Config } from "../config/index";
6
10
  import type { Screen } from "@sudobility/testomniac_types";
7
11
 
12
+ type Launcher = typeof puppeteer.launch;
13
+
8
14
  /**
9
15
  * Finds the latest Chrome for Testing binary in the Puppeteer cache.
10
16
  * Falls back to common system paths, then to the config value.
@@ -64,10 +70,15 @@ export function resolveChromiumPath(configPath: string): string {
64
70
  export class ChromiumManager {
65
71
  private browser: Browser | null = null;
66
72
 
67
- constructor(private config: Config) {}
73
+ constructor(
74
+ private config: Config,
75
+ private launcher: Launcher = puppeteer.launch
76
+ ) {}
68
77
 
78
+ /** Launch the shared browser. Idempotent — reuses the existing one. */
69
79
  async launch(): Promise<Browser> {
70
- this.browser = await puppeteer.launch({
80
+ if (this.browser) return this.browser;
81
+ this.browser = await this.launcher({
71
82
  executablePath: resolveChromiumPath(this.config.chromiumPath),
72
83
  userDataDir: this.config.userDataDir,
73
84
  headless: true,
@@ -85,6 +96,28 @@ export class ChromiumManager {
85
96
  return page;
86
97
  }
87
98
 
99
+ /**
100
+ * Create an isolated browser context (own cookies/cache/storage) and a page
101
+ * within it. Use one per concurrent run so runs don't share profile state.
102
+ */
103
+ async createContext(
104
+ screen?: Screen
105
+ ): Promise<{ context: BrowserContext; page: Page }> {
106
+ if (!this.browser) throw new Error("Browser not launched");
107
+ const context = await this.browser.createBrowserContext();
108
+ const page = await context.newPage();
109
+ if (screen) {
110
+ await page.setViewport({ width: screen.width, height: screen.height });
111
+ }
112
+ return { context, page };
113
+ }
114
+
115
+ /** Close a single run's context (not the shared browser). */
116
+ async closeContext(context: BrowserContext): Promise<void> {
117
+ await context.close();
118
+ }
119
+
120
+ /** Close the whole browser. Process shutdown only. */
88
121
  async close(): Promise<void> {
89
122
  if (this.browser) {
90
123
  await this.browser.close();
@@ -26,6 +26,9 @@ export function loadConfig(): Config {
26
26
  signicEmailApiUrl:
27
27
  process.env.SIGNIC_EMAIL_API_URL || "https://api.signic.email/api",
28
28
  chromiumPath: process.env.CHROMIUM_PATH || "/usr/bin/chromium",
29
+ // Single browser per worker; each run uses an isolated BrowserContext
30
+ // (own cookies/cache/storage), so this dir is shared safely. Do NOT shard
31
+ // per-run — context isolation already prevents cross-run profile contention.
29
32
  userDataDir: process.env.USER_DATA_DIR || "./testomniac-browser-profile",
30
33
  artifactDir: process.env.ARTIFACT_DIR || "./testomniac-artifacts",
31
34
  maxConcurrentRunners: Number(process.env.MAX_CONCURRENT_RUNNERS ?? 5),
package/src/index.ts CHANGED
@@ -4,6 +4,7 @@ import pino from "pino";
4
4
  import { loadConfig } from "./config/index";
5
5
  import { RunnerManager } from "./runner-manager";
6
6
  import { runFullScan, runSequenceScan } from "./orchestrator";
7
+ import { ChromiumManager } from "./browser/chromium";
7
8
 
8
9
  const logger = pino({
9
10
  name: "testomniac_runner",
@@ -14,7 +15,12 @@ const port = Number(process.env.PORT ?? 8030);
14
15
  const config = loadConfig();
15
16
  const pollIntervalMs = Number(process.env.SCAN_POLL_INTERVAL_MS ?? 10_000);
16
17
  const maxConcurrentRunners = config.maxConcurrentRunners;
17
- const runnerManager = new RunnerManager(pollIntervalMs, maxConcurrentRunners);
18
+ const chromium = new ChromiumManager(config);
19
+ const runnerManager = new RunnerManager(
20
+ pollIntervalMs,
21
+ maxConcurrentRunners,
22
+ chromium
23
+ );
18
24
  const runnerInstanceId =
19
25
  process.env.TESTOMNIAC_RUNNER_INSTANCE_ID ??
20
26
  process.env.TESTOMNIAC_RUNNER_PROCESS_INSTANCE_ID;
@@ -97,7 +103,9 @@ if (import.meta.main) {
97
103
  process.exit(1);
98
104
  }
99
105
  } else {
100
- // Default: polling mode
106
+ // Default: polling mode — launch the shared browser once up front.
107
+ await runnerManager.start();
108
+
101
109
  const pollInterval = setInterval(() => {
102
110
  void runnerManager.tick();
103
111
  }, pollIntervalMs);
@@ -147,7 +155,7 @@ if (import.meta.main) {
147
155
  const check = setInterval(() => {
148
156
  if (runnerManager.getActiveRunCount() === 0) {
149
157
  clearInterval(check);
150
- process.exit(0);
158
+ void runnerManager.shutdown().finally(() => process.exit(0));
151
159
  }
152
160
  }, 500);
153
161
  setTimeout(() => {
@@ -1,4 +1,5 @@
1
1
  import pino from "pino";
2
+ import type { Page } from "puppeteer-core";
2
3
  import { ChromiumManager } from "./browser/chromium";
3
4
  import { loadConfig } from "./config/index";
4
5
  import { computeSummary } from "./runner/reporter";
@@ -42,6 +43,12 @@ export interface RunOptions {
42
43
  quickScan?: boolean;
43
44
  scanMode?: "full" | "partial" | "minimum";
44
45
  signal?: AbortSignal;
46
+ /**
47
+ * A page from a shared browser (polling mode reuses one browser across runs
48
+ * with an isolated context per run). When omitted (one-shot CLI mode) a
49
+ * one-off browser is launched and torn down here.
50
+ */
51
+ page?: Page;
45
52
  }
46
53
 
47
54
  export async function runFullScan(options: RunOptions): Promise<void> {
@@ -53,15 +60,18 @@ export async function runFullScan(options: RunOptions): Promise<void> {
53
60
 
54
61
  const runStart = Date.now();
55
62
 
56
- const chromium = new ChromiumManager(config);
57
- await chromium.launch();
63
+ // Reuse an injected page when provided (shared browser); otherwise
64
+ // self-manage a one-off browser for this single run.
65
+ const ownsBrowser = options.page == null;
66
+ const chromium = ownsBrowser ? new ChromiumManager(config) : null;
67
+ if (chromium) await chromium.launch();
58
68
  const runnerId = options.runnerId;
59
69
  const scanId = options.scanId;
60
70
 
61
71
  try {
62
72
  const defaultScreen: Screen =
63
73
  sizeClass === SizeClass.Desktop ? DESKTOP_SCREENS[0] : MOBILE_SCREENS[0];
64
- const page = await chromium.newPage(defaultScreen);
74
+ const page = options.page ?? (await chromium!.newPage(defaultScreen));
65
75
  const adapter = new PuppeteerAdapter(page);
66
76
 
67
77
  const eventHandler: ScanEventHandler & {
@@ -144,10 +154,14 @@ export async function runFullScan(options: RunOptions): Promise<void> {
144
154
  });
145
155
  }
146
156
 
147
- try {
148
- await page.close();
149
- } catch (err) {
150
- logger.debug({ err }, "page already closed during scan cleanup");
157
+ // When we own the browser, close the page here. When the page was
158
+ // injected, the caller (RunnerManager.withRunContext) closes its context.
159
+ if (ownsBrowser) {
160
+ try {
161
+ await page.close();
162
+ } catch (err) {
163
+ logger.debug({ err }, "page already closed during scan cleanup");
164
+ }
151
165
  }
152
166
 
153
167
  logger.info(
@@ -155,7 +169,7 @@ export async function runFullScan(options: RunOptions): Promise<void> {
155
169
  "run complete"
156
170
  );
157
171
  } finally {
158
- await chromium.close();
172
+ if (chromium) await chromium.close();
159
173
  }
160
174
  }
161
175
 
@@ -0,0 +1,40 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import { RunnerManager } from "./runner-manager";
3
+
4
+ describe("RunnerManager context lifecycle", () => {
5
+ it("acquires and releases a context for each run, even on failure", async () => {
6
+ const context = {};
7
+ const page = {};
8
+ const chromium = {
9
+ launch: vi.fn().mockResolvedValue({}),
10
+ createContext: vi.fn().mockResolvedValue({ context, page }),
11
+ closeContext: vi.fn().mockResolvedValue(undefined),
12
+ close: vi.fn().mockResolvedValue(undefined),
13
+ };
14
+ const mgr = new RunnerManager(1000, 2, chromium as never);
15
+
16
+ await expect(
17
+ mgr.withRunContext(async p => {
18
+ expect(p).toBe(page);
19
+ throw new Error("boom");
20
+ })
21
+ ).rejects.toThrow("boom");
22
+
23
+ expect(chromium.createContext).toHaveBeenCalledTimes(1);
24
+ expect(chromium.closeContext).toHaveBeenCalledWith(context);
25
+ });
26
+
27
+ it("start() launches the shared browser; shutdown() closes it", async () => {
28
+ const chromium = {
29
+ launch: vi.fn().mockResolvedValue({}),
30
+ createContext: vi.fn(),
31
+ closeContext: vi.fn(),
32
+ close: vi.fn().mockResolvedValue(undefined),
33
+ };
34
+ const mgr = new RunnerManager(1000, 1, chromium as never);
35
+ await mgr.start();
36
+ await mgr.shutdown();
37
+ expect(chromium.launch).toHaveBeenCalledTimes(1);
38
+ expect(chromium.close).toHaveBeenCalledTimes(1);
39
+ });
40
+ });
@@ -1,7 +1,9 @@
1
1
  import pino from "pino";
2
+ import type { Page } from "puppeteer-core";
2
3
  import { getApiClient } from "@sudobility/testomniac_runner_service";
3
4
  import { loadConfig } from "./config/index";
4
5
  import { runFullScan } from "./orchestrator";
6
+ import { ChromiumManager } from "./browser/chromium";
5
7
 
6
8
  const logger = pino({
7
9
  name: "runner_manager",
@@ -22,9 +24,31 @@ export class RunnerManager {
22
24
 
23
25
  constructor(
24
26
  private readonly pollIntervalMs: number,
25
- private readonly maxConcurrentRunners: number
27
+ private readonly maxConcurrentRunners: number,
28
+ private readonly chromium: ChromiumManager
26
29
  ) {}
27
30
 
31
+ /** Launch the shared browser once before polling begins. */
32
+ async start(): Promise<void> {
33
+ await this.chromium.launch();
34
+ }
35
+
36
+ /** Stop active runs and close the shared browser (process shutdown). */
37
+ async shutdown(): Promise<void> {
38
+ this.stopAllRuns();
39
+ await this.chromium.close();
40
+ }
41
+
42
+ /** Run `fn(page)` in a fresh isolated context, always releasing it. */
43
+ async withRunContext<T>(fn: (page: Page) => Promise<T>): Promise<T> {
44
+ const { context, page } = await this.chromium.createContext();
45
+ try {
46
+ return await fn(page);
47
+ } finally {
48
+ await this.chromium.closeContext(context);
49
+ }
50
+ }
51
+
28
52
  getActiveRunCount(): number {
29
53
  return this.activeRuns.size;
30
54
  }
@@ -176,19 +200,22 @@ export class RunnerManager {
176
200
  const api = getApiClient(config.apiUrl, config.scannerApiKey);
177
201
 
178
202
  try {
179
- await runFullScan({
180
- runnerId: params.runnerId,
181
- scanId: params.runId,
182
- scanUrl: params.scanUrl,
183
- baseUrl: params.scanUrl,
184
- sizeClass: params.sizeClass,
185
- runnerName: params.runnerName,
186
- runnerInstanceId: params.runnerInstanceId,
187
- runnerInstanceName: params.runnerInstanceName,
188
- quickScan: params.quickScan,
189
- scanMode: params.scanMode,
190
- signal: params.signal,
191
- });
203
+ await this.withRunContext(page =>
204
+ runFullScan({
205
+ page,
206
+ runnerId: params.runnerId,
207
+ scanId: params.runId,
208
+ scanUrl: params.scanUrl,
209
+ baseUrl: params.scanUrl,
210
+ sizeClass: params.sizeClass,
211
+ runnerName: params.runnerName,
212
+ runnerInstanceId: params.runnerInstanceId,
213
+ runnerInstanceName: params.runnerInstanceName,
214
+ quickScan: params.quickScan,
215
+ scanMode: params.scanMode,
216
+ signal: params.signal,
217
+ })
218
+ );
192
219
 
193
220
  logger.info({ runId: params.runId }, "run completed successfully");
194
221
  } catch (error) {