@sudobility/testomniac_runner 0.0.129 → 0.0.131

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/.env.example CHANGED
@@ -1,67 +1,77 @@
1
1
  # =============================================================================
2
- # Testomniac Scanner Environment Variables
2
+ # Testomniac Runner Environment Variables
3
3
  # =============================================================================
4
4
  # Copy this file to .env and fill in your values.
5
5
 
6
6
  # -----------------------------------------------------------------------------
7
7
  # Server
8
8
  # -----------------------------------------------------------------------------
9
+
10
+ # Port for the health/status HTTP endpoint (Hono server)
9
11
  PORT=8030
10
- NODE_ENV=development
12
+
13
+ # Pino log level: trace | debug | info | warn | error | fatal
11
14
  LOG_LEVEL=info
12
15
 
13
16
  # -----------------------------------------------------------------------------
14
- # API Connection (Required)
17
+ # API
15
18
  # -----------------------------------------------------------------------------
16
- # URL of the testomniac_api instance
17
- TESTOMNIAC_API_URL=http://localhost:8027
18
19
 
19
- # Shared secret for authenticating with the API. Must match SCANNER_API_KEY in the API's .env.
20
+ # URL of the testomniac_api instance this runner polls for pending runs
21
+ TESTOMNIAC_API_URL=https://api.testomniac.com
22
+
23
+ # Shared secret for X-Scanner-Key auth. Must match SCANNER_API_KEY in the API's .env.
20
24
  # Generate with: openssl rand -hex 32
21
25
  SCANNER_API_KEY=
22
26
 
23
27
  # -----------------------------------------------------------------------------
24
- # Scan Polling
28
+ # Polling & Concurrency
25
29
  # -----------------------------------------------------------------------------
26
- # How often (ms) the scanner polls the API for pending runs
30
+
31
+ # How often (ms) the runner polls the API for pending runs
27
32
  SCAN_POLL_INTERVAL_MS=10000
28
33
 
34
+ # Maximum number of test runs executing in parallel
35
+ MAX_CONCURRENT_RUNNERS=5
36
+
29
37
  # -----------------------------------------------------------------------------
30
- # OpenAI (Required for AI analysis phase)
38
+ # Browser
31
39
  # -----------------------------------------------------------------------------
32
- OPENAI_API_KEY=
40
+
41
+ # Path to Chrome/Chromium binary. Auto-detected from Puppeteer cache if not set.
42
+ # Only set this to override auto-detection (e.g. Docker: /usr/bin/chromium).
43
+ # CHROMIUM_PATH=/usr/bin/chromium
44
+
45
+ # Directory for persistent browser profile (cookies, cache, etc.)
46
+ USER_DATA_DIR=./testomniac-browser-profile
47
+
48
+ # Directory for screenshots and other scan artifacts (JPEG, quality 72)
49
+ ARTIFACT_DIR=./testomniac-artifacts
33
50
 
34
51
  # -----------------------------------------------------------------------------
35
- # Email Reports (Optional - only needed if sending scan report emails)
52
+ # Email Reports (Optional only needed if sending scan report emails)
36
53
  # -----------------------------------------------------------------------------
37
- # Postmark API token for sending emails
54
+
55
+ # Postmark API token for sending emails. Leave empty to disable email reports.
38
56
  POSTMARK_SERVER_TOKEN=
39
57
 
40
- # Sender email address for report emails
58
+ # Sender email address shown on report emails
41
59
  POSTMARK_FROM_EMAIL=
42
60
 
43
- # Secret key for signing deep-link JWTs in report emails.
44
- # If not set, deep links will use an empty key (insecure but functional).
61
+ # Secret for signing deep-link JWTs embedded in report emails.
62
+ # If empty, deep links use an empty key (insecure fine for local dev).
45
63
  # Generate with: openssl rand -hex 32
46
64
  DEEP_LINK_SECRET=
47
65
 
48
- # Base URL of the frontend app, used for building deep-link URLs in emails
66
+ # Base URL of the frontend app, used to build deep-link URLs in emails
49
67
  APP_BASE_URL=http://localhost:3000
50
68
 
51
69
  # -----------------------------------------------------------------------------
52
- # Signic Email Verification (Optional - for email-based auth scanning)
70
+ # Signic Email Verification (Optional for email-based auth scanning)
53
71
  # -----------------------------------------------------------------------------
54
- SIGNIC_INDEXER_URL=https://api.signic.email/idx
55
- SIGNIC_WILDDUCK_URL=https://api.signic.email/api
56
72
 
57
- # -----------------------------------------------------------------------------
58
- # Browser / Filesystem
59
- # -----------------------------------------------------------------------------
60
- # Path to Chromium binary
61
- CHROMIUM_PATH=/usr/bin/chromium
62
-
63
- # Directory for browser profile persistence
64
- USER_DATA_DIR=./browser-profile
73
+ # Signic indexer URL for polling verification/confirmation emails
74
+ SIGNIC_INDEXER_URL=https://api.signic.email/idx
65
75
 
66
- # Directory for storing screenshots and other scan artifacts
67
- ARTIFACT_DIR=./artifacts
76
+ # Signic email API URL for disposable email account management
77
+ SIGNIC_EMAIL_API_URL=https://api.signic.email/api
package/bun.lock CHANGED
@@ -8,8 +8,8 @@
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.128",
12
- "@sudobility/testomniac_types": "^0.0.67",
11
+ "@sudobility/testomniac_runner_service": "^0.1.130",
12
+ "@sudobility/testomniac_types": "^0.0.68",
13
13
  "hono": "^4.7.0",
14
14
  "jose": "^6.1.2",
15
15
  "openai": "^6.7.0",
@@ -172,9 +172,9 @@
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.128", "", { "peerDependencies": { "@sudobility/testomniac_types": "^0.0.67", "openai": ">=6.0.0", "react": ">=18.0.0" }, "optionalPeers": ["openai", "react"] }, "sha512-YWFjcMmZROSHSNUZLt9czUJeWoYmIj69JCXUP9CD9sw5/UTzFFLwIdkqtAE2lG4RDo/Yb/5heZPmffZ1EfPPqw=="],
175
+ "@sudobility/testomniac_runner_service": ["@sudobility/testomniac_runner_service@0.1.130", "", { "peerDependencies": { "@sudobility/testomniac_types": "^0.0.68", "openai": ">=6.0.0", "react": ">=18.0.0" }, "optionalPeers": ["openai", "react"] }, "sha512-C6I/8Zo/ZqBrOMXeiWqaAKEJIEdRI6xWvtRQoyYEtxuh51WN8CtbVyhPn5AEzDQJQTWa+RJllo9u0SxW0L/o1g=="],
176
176
 
177
- "@sudobility/testomniac_types": ["@sudobility/testomniac_types@0.0.67", "", { "peerDependencies": { "@sudobility/types": "^1.9.62" } }, "sha512-kNBDk7AsFx1rqUWLq4nx0nf1alV9G7U5xbxj8qDvZKa+cCnUSiZFRtDB88ENWQzeIPVJAU8MaVo5yUKVL46eYg=="],
177
+ "@sudobility/testomniac_types": ["@sudobility/testomniac_types@0.0.68", "", { "peerDependencies": { "@sudobility/types": "^1.9.62" } }, "sha512-io3qbAuOLuCrjC4txkD+B21n/RGrfz2MH+W0XciAOkaxcfXagdHRUdWie/520HeItC9F91jP9SA67YAcx5aZ0A=="],
178
178
 
179
179
  "@sudobility/types": ["@sudobility/types@1.9.61", "", {}, "sha512-SODGpstB/iKfK3H/4BvJx/FBcc1h3gutUjGotyxN19VnOfWyzaDoEmW7eyoxOAYhZyXMXagSiii+NIEZvuxKog=="],
180
180
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sudobility/testomniac_runner",
3
- "version": "0.0.129",
3
+ "version": "0.0.131",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -24,8 +24,8 @@
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.128",
28
- "@sudobility/testomniac_types": "^0.0.67",
27
+ "@sudobility/testomniac_runner_service": "^0.1.130",
28
+ "@sudobility/testomniac_types": "^0.0.68",
29
29
  "hono": "^4.7.0",
30
30
  "jose": "^6.1.2",
31
31
  "openai": "^6.7.0",
@@ -241,9 +241,10 @@ export class PuppeteerAdapter implements BrowserAdapter {
241
241
  type?: string;
242
242
  quality?: number;
243
243
  }): Promise<Uint8Array> {
244
+ const type = (options?.type as "jpeg" | "png") || "jpeg";
244
245
  const buffer = await this.page.screenshot({
245
- type: (options?.type as "jpeg" | "png") || "jpeg",
246
- quality: options?.quality || 72,
246
+ type,
247
+ ...(type === "jpeg" ? { quality: options?.quality || 72 } : {}),
247
248
  });
248
249
  return new Uint8Array(buffer);
249
250
  }
@@ -51,7 +51,7 @@ export async function autoRegister(
51
51
 
52
52
  const client = new SignicClient({
53
53
  indexerUrl: config.signicIndexerUrl,
54
- wildduckUrl: config.signicWildduckUrl,
54
+ wildduckUrl: config.signicEmailApiUrl,
55
55
  signMessage: async (message: string) => signEthMessage(message, privateKey),
56
56
  });
57
57
 
@@ -1,7 +1,66 @@
1
+ import { existsSync, readdirSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
1
4
  import puppeteer, { type Browser, type Page } from "puppeteer-core";
2
5
  import type { Config } from "../config/index";
3
6
  import type { Screen } from "@sudobility/testomniac_types";
4
7
 
8
+ /**
9
+ * Finds the latest Chrome for Testing binary in the Puppeteer cache.
10
+ * Falls back to common system paths, then to the config value.
11
+ */
12
+ export function resolveChromiumPath(configPath: string): string {
13
+ // Explicit env var / config always wins
14
+ if (process.env.CHROMIUM_PATH) return process.env.CHROMIUM_PATH;
15
+
16
+ // Scan Puppeteer cache for the latest Chrome for Testing
17
+ const cacheDir = join(homedir(), ".cache", "puppeteer", "chrome");
18
+ if (existsSync(cacheDir)) {
19
+ const platform =
20
+ process.platform === "darwin"
21
+ ? process.arch === "arm64"
22
+ ? "mac_arm"
23
+ : "mac-x64"
24
+ : "linux";
25
+ const versions = readdirSync(cacheDir)
26
+ .filter(d => d.startsWith(platform + "-"))
27
+ .sort()
28
+ .reverse();
29
+
30
+ for (const version of versions) {
31
+ const candidates =
32
+ process.platform === "darwin"
33
+ ? [
34
+ join(
35
+ cacheDir,
36
+ version,
37
+ "chrome-mac-arm64",
38
+ "Google Chrome for Testing.app",
39
+ "Contents",
40
+ "MacOS",
41
+ "Google Chrome for Testing"
42
+ ),
43
+ join(
44
+ cacheDir,
45
+ version,
46
+ "chrome-mac-x64",
47
+ "Google Chrome for Testing.app",
48
+ "Contents",
49
+ "MacOS",
50
+ "Google Chrome for Testing"
51
+ ),
52
+ ]
53
+ : [join(cacheDir, version, "chrome-linux64", "chrome")];
54
+
55
+ for (const candidate of candidates) {
56
+ if (existsSync(candidate)) return candidate;
57
+ }
58
+ }
59
+ }
60
+
61
+ return configPath;
62
+ }
63
+
5
64
  export class ChromiumManager {
6
65
  private browser: Browser | null = null;
7
66
 
@@ -9,7 +68,7 @@ export class ChromiumManager {
9
68
 
10
69
  async launch(): Promise<Browser> {
11
70
  this.browser = await puppeteer.launch({
12
- executablePath: this.config.chromiumPath,
71
+ executablePath: resolveChromiumPath(this.config.chromiumPath),
13
72
  userDataDir: this.config.userDataDir,
14
73
  headless: true,
15
74
  args: ["--no-sandbox", "--disable-setuid-sandbox"],
@@ -17,7 +17,7 @@ describe("config", () => {
17
17
 
18
18
  it("uses defaults for optional values", () => {
19
19
  const config = loadConfig();
20
- expect(config.artifactDir).toBe("./artifacts");
21
- expect(config.userDataDir).toBe("./browser-profile");
20
+ expect(config.artifactDir).toBe("./testomniac-artifacts");
21
+ expect(config.userDataDir).toBe("./testomniac-browser-profile");
22
22
  });
23
23
  });
@@ -1,13 +1,12 @@
1
1
  export interface Config {
2
2
  apiUrl: string;
3
3
  scannerApiKey: string;
4
- openaiApiKey: string;
5
4
  postmarkServerToken: string;
6
5
  postmarkFromEmail: string;
7
6
  deepLinkSecret: string;
8
7
  appBaseUrl: string;
9
8
  signicIndexerUrl: string;
10
- signicWildduckUrl: string;
9
+ signicEmailApiUrl: string;
11
10
  chromiumPath: string;
12
11
  userDataDir: string;
13
12
  artifactDir: string;
@@ -16,20 +15,19 @@ export interface Config {
16
15
 
17
16
  export function loadConfig(): Config {
18
17
  return {
19
- apiUrl: process.env.TESTOMNIAC_API_URL || "http://localhost:8027",
18
+ apiUrl: process.env.TESTOMNIAC_API_URL || "https://api.testomniac.com",
20
19
  scannerApiKey: process.env.SCANNER_API_KEY || "",
21
- openaiApiKey: process.env.OPENAI_API_KEY || "",
22
20
  postmarkServerToken: process.env.POSTMARK_SERVER_TOKEN || "",
23
21
  postmarkFromEmail: process.env.POSTMARK_FROM_EMAIL || "",
24
22
  deepLinkSecret: process.env.DEEP_LINK_SECRET || "",
25
23
  appBaseUrl: process.env.APP_BASE_URL || "http://localhost:3000",
26
24
  signicIndexerUrl:
27
25
  process.env.SIGNIC_INDEXER_URL || "https://api.signic.email/idx",
28
- signicWildduckUrl:
29
- process.env.SIGNIC_WILDDUCK_URL || "https://api.signic.email/api",
26
+ signicEmailApiUrl:
27
+ process.env.SIGNIC_EMAIL_API_URL || "https://api.signic.email/api",
30
28
  chromiumPath: process.env.CHROMIUM_PATH || "/usr/bin/chromium",
31
- userDataDir: process.env.USER_DATA_DIR || "./browser-profile",
32
- artifactDir: process.env.ARTIFACT_DIR || "./artifacts",
29
+ userDataDir: process.env.USER_DATA_DIR || "./testomniac-browser-profile",
30
+ artifactDir: process.env.ARTIFACT_DIR || "./testomniac-artifacts",
33
31
  maxConcurrentRunners: Number(process.env.MAX_CONCURRENT_RUNNERS ?? 5),
34
32
  };
35
33
  }
package/src/index.ts CHANGED
@@ -44,6 +44,7 @@ if (import.meta.main) {
44
44
  "runner-id": { type: "string" },
45
45
  "base-url": { type: "string" },
46
46
  "size-class": { type: "string", default: "desktop" },
47
+ "scan-mode": { type: "string" },
47
48
  },
48
49
  strict: false,
49
50
  });
@@ -54,6 +55,7 @@ if (import.meta.main) {
54
55
  const runnerId = Number(args["runner-id"]);
55
56
  const baseUrl = String(args["base-url"] ?? "");
56
57
  const sizeClass = String(args["size-class"] ?? "desktop");
58
+ const scanMode = readScanMode(args["scan-mode"]);
57
59
 
58
60
  logger.info({ scanId, runnerId }, "one-shot mode: executing test run");
59
61
  try {
@@ -63,6 +65,7 @@ if (import.meta.main) {
63
65
  scanUrl: baseUrl,
64
66
  baseUrl,
65
67
  sizeClass,
68
+ scanMode,
66
69
  runnerInstanceId: crypto.randomUUID(),
67
70
  runnerInstanceName: "mcp-runner",
68
71
  });
@@ -104,6 +107,14 @@ if (import.meta.main) {
104
107
  }
105
108
  }
106
109
 
110
+ function readScanMode(
111
+ value: unknown
112
+ ): "full" | "partial" | "minimum" | undefined {
113
+ return value === "full" || value === "partial" || value === "minimum"
114
+ ? value
115
+ : undefined;
116
+ }
117
+
107
118
  export default {
108
119
  port,
109
120
  fetch: app.fetch,
@@ -40,14 +40,12 @@ export interface RunOptions {
40
40
  loginUrl?: string;
41
41
  entityCredentialId?: number;
42
42
  quickScan?: boolean;
43
+ scanMode?: "full" | "partial" | "minimum";
43
44
  }
44
45
 
45
46
  export async function runFullScan(options: RunOptions): Promise<void> {
46
47
  const config = loadConfig();
47
- const api = getApiClient(
48
- config.apiUrl + "/api/v1/scanner",
49
- config.scannerApiKey
50
- );
48
+ const api = getApiClient(config.apiUrl, config.scannerApiKey);
51
49
  const { scanUrl, baseUrl, userEmail } = options;
52
50
  const runnerName = options.runnerName || new URL(scanUrl).hostname;
53
51
  const sizeClass = (options.sizeClass as SizeClass) || SizeClass.Desktop;
@@ -115,6 +113,7 @@ export async function runFullScan(options: RunOptions): Promise<void> {
115
113
  entityCredentialId: options.entityCredentialId,
116
114
  credentials: options.credentials,
117
115
  quickScan: options.quickScan,
116
+ scanMode: options.scanMode,
118
117
  },
119
118
  api,
120
119
  expertises,
@@ -138,7 +137,11 @@ export async function runFullScan(options: RunOptions): Promise<void> {
138
137
  });
139
138
  }
140
139
 
141
- await page.close();
140
+ try {
141
+ await page.close();
142
+ } catch (err) {
143
+ logger.debug({ err }, "page already closed during scan cleanup");
144
+ }
142
145
 
143
146
  logger.info(
144
147
  { scanId, sizeClass, totalDurationMs: elapsed(runStart) },
@@ -159,10 +162,7 @@ export async function runSequenceScan(
159
162
  options: SequenceRunOptions
160
163
  ): Promise<void> {
161
164
  const config = loadConfig();
162
- const api = getApiClient(
163
- config.apiUrl + "/api/v1/scanner",
164
- config.scannerApiKey
165
- );
165
+ const api = getApiClient(config.apiUrl, config.scannerApiKey);
166
166
 
167
167
  const runStart = Date.now();
168
168
  const sizeClass = (options.sizeClass as SizeClass) || SizeClass.Desktop;
@@ -205,7 +205,11 @@ export async function runSequenceScan(
205
205
  eventHandler
206
206
  );
207
207
 
208
- await page.close();
208
+ try {
209
+ await page.close();
210
+ } catch (err) {
211
+ logger.debug({ err }, "page already closed during sequence cleanup");
212
+ }
209
213
 
210
214
  logger.info(
211
215
  {
@@ -33,10 +33,7 @@ export class RunnerManager {
33
33
  this.tickInFlight = true;
34
34
  try {
35
35
  const config = loadConfig();
36
- const api = getApiClient(
37
- config.apiUrl + "/api/v1/scanner",
38
- config.scannerApiKey
39
- );
36
+ const api = getApiClient(config.apiUrl, config.scannerApiKey);
40
37
 
41
38
  while (this.activeRuns.size < this.maxConcurrentRunners) {
42
39
  const pendingRun = await api.getPendingTestRun();
@@ -57,23 +54,50 @@ export class RunnerManager {
57
54
  }
58
55
 
59
56
  const slotNumber = this.activeRuns.size + 1;
60
- const runner = await api.getRunner(pendingRun.runnerId);
57
+
58
+ let runner;
59
+ try {
60
+ runner = await api.getRunner(pendingRun.runnerId);
61
+ } catch (err) {
62
+ logger.error(
63
+ { err, runnerId: pendingRun.runnerId, runId: pendingRun.id },
64
+ "failed to fetch runner, will retry next tick"
65
+ );
66
+ break;
67
+ }
61
68
  if (!runner) {
62
69
  logger.error(
63
70
  { runnerId: pendingRun.runnerId, runId: pendingRun.id },
64
71
  "runner not found for pending run"
65
72
  );
66
- await api.completeTestRun(pendingRun.id, { status: "failed" });
73
+ try {
74
+ await api.completeTestRun(pendingRun.id, { status: "failed" });
75
+ } catch (err) {
76
+ logger.error(
77
+ { err, runId: pendingRun.id },
78
+ "failed to mark run as failed"
79
+ );
80
+ }
67
81
  continue;
68
82
  }
69
83
 
70
84
  const runnerInstanceId = `${this.processInstanceId}:${slotNumber}`;
71
85
  const runnerInstanceName = `${runner.title} [slot ${slotNumber}]`;
72
- const claimed = await api.claimTestRun(
73
- pendingRun.id,
74
- runnerInstanceId,
75
- runnerInstanceName
76
- );
86
+
87
+ let claimed;
88
+ try {
89
+ claimed = await api.claimTestRun(
90
+ pendingRun.id,
91
+ runnerInstanceId,
92
+ runnerInstanceName
93
+ );
94
+ } catch (err) {
95
+ logger.error(
96
+ { err, runId: pendingRun.id },
97
+ "failed to claim run, will retry next tick"
98
+ );
99
+ break;
100
+ }
77
101
  if (!claimed) {
78
102
  logger.info(
79
103
  { runId: pendingRun.id },
@@ -106,8 +130,11 @@ export class RunnerManager {
106
130
  runnerInstanceId,
107
131
  runnerInstanceName,
108
132
  quickScan: pendingRun.quickScan ?? false,
133
+ scanMode: readScanMode(pendingRun),
109
134
  });
110
135
  }
136
+ } catch (err) {
137
+ logger.error({ err }, "tick failed");
111
138
  } finally {
112
139
  this.tickInFlight = false;
113
140
  }
@@ -122,6 +149,7 @@ export class RunnerManager {
122
149
  runnerInstanceId: string;
123
150
  runnerInstanceName: string;
124
151
  quickScan: boolean;
152
+ scanMode?: "full" | "partial" | "minimum";
125
153
  }): Promise<void> {
126
154
  const config = loadConfig();
127
155
  const api = getApiClient(
@@ -140,6 +168,7 @@ export class RunnerManager {
140
168
  runnerInstanceId: params.runnerInstanceId,
141
169
  runnerInstanceName: params.runnerInstanceName,
142
170
  quickScan: params.quickScan,
171
+ scanMode: params.scanMode,
143
172
  });
144
173
 
145
174
  logger.info({ runId: params.runId }, "run completed successfully");
@@ -161,3 +190,12 @@ export class RunnerManager {
161
190
  }
162
191
  }
163
192
  }
193
+
194
+ function readScanMode(
195
+ run: unknown
196
+ ): "full" | "partial" | "minimum" | undefined {
197
+ const value = (run as { scanMode?: unknown }).scanMode;
198
+ return value === "full" || value === "partial" || value === "minimum"
199
+ ? value
200
+ : undefined;
201
+ }