@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 +2 -2
- package/package.json +2 -2
- package/src/browser/chromium.test.ts +44 -0
- package/src/browser/chromium.ts +36 -3
- package/src/config/index.ts +3 -0
- package/src/index.ts +11 -3
- package/src/orchestrator.ts +22 -8
- package/src/runner-manager.test.ts +40 -0
- package/src/runner-manager.ts +41 -14
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
+
});
|
package/src/browser/chromium.ts
CHANGED
|
@@ -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, {
|
|
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(
|
|
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
|
|
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();
|
package/src/config/index.ts
CHANGED
|
@@ -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
|
|
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(() => {
|
package/src/orchestrator.ts
CHANGED
|
@@ -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
|
-
|
|
57
|
-
|
|
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
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
+
});
|
package/src/runner-manager.ts
CHANGED
|
@@ -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
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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) {
|