@sudobility/testomniac_runner 0.0.128
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/.dockerignore +75 -0
- package/.env.example +67 -0
- package/.github/workflows/ci-cd.yml +30 -0
- package/.prettierignore +62 -0
- package/.prettierrc +11 -0
- package/.vscode/settings.json +29 -0
- package/CLAUDE.md +170 -0
- package/Dockerfile +76 -0
- package/README.md +22 -0
- package/bun.lock +707 -0
- package/docs/superpowers/specs/2026-04-20-smarter-scanner-navigation-design.md +121 -0
- package/eslint.config.js +80 -0
- package/package.json +55 -0
- package/plans/DATA.md +703 -0
- package/plans/POLLING.md +569 -0
- package/plans/RUNNER.md +288 -0
- package/src/adapters/PuppeteerAdapter.ts +394 -0
- package/src/auth/credential-manager.ts +17 -0
- package/src/auth/form-identifier.test.ts +136 -0
- package/src/auth/form-identifier.ts +54 -0
- package/src/auth/login-executor.ts +112 -0
- package/src/auth/password-detector.test.ts +61 -0
- package/src/auth/password-detector.ts +119 -0
- package/src/auth/signic-registrar.ts +186 -0
- package/src/browser/chromium.ts +35 -0
- package/src/config/index.test.ts +23 -0
- package/src/config/index.ts +35 -0
- package/src/email/deep-link.test.ts +17 -0
- package/src/email/deep-link.ts +23 -0
- package/src/email/sender.ts +35 -0
- package/src/email/templates.ts +34 -0
- package/src/index.test.ts +17 -0
- package/src/index.ts +110 -0
- package/src/orchestrator.ts +220 -0
- package/src/plugins/content/ai-checks.ts +115 -0
- package/src/plugins/content/checks.test.ts +49 -0
- package/src/plugins/content/checks.ts +141 -0
- package/src/plugins/content/index.ts +73 -0
- package/src/plugins/registry.test.ts +49 -0
- package/src/plugins/registry.ts +21 -0
- package/src/plugins/security/header-checks.ts +56 -0
- package/src/plugins/security/html-checks.ts +93 -0
- package/src/plugins/security/index.ts +58 -0
- package/src/plugins/security/network-checks.test.ts +74 -0
- package/src/plugins/security/network-checks.ts +136 -0
- package/src/plugins/seo/checks.test.ts +70 -0
- package/src/plugins/seo/checks.ts +173 -0
- package/src/plugins/seo/index.ts +85 -0
- package/src/plugins/types.ts +43 -0
- package/src/plugins/ui-consistency/comparator.test.ts +108 -0
- package/src/plugins/ui-consistency/comparator.ts +58 -0
- package/src/plugins/ui-consistency/index.ts +36 -0
- package/src/plugins/ui-consistency/style-extractor.ts +79 -0
- package/src/runner/executor.test.ts +37 -0
- package/src/runner/executor.ts +167 -0
- package/src/runner/reporter.ts +19 -0
- package/src/runner/worker-pool.ts +106 -0
- package/src/runner-manager.ts +163 -0
- package/src/scanner/email-checker.ts +106 -0
- package/tsconfig.json +21 -0
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import type { Page } from "puppeteer-core";
|
|
2
|
+
import type { LegacyTestAction } from "@sudobility/testomniac_types";
|
|
3
|
+
import { ACTION_TIMEOUT_MS } from "@sudobility/testomniac_runner_service";
|
|
4
|
+
import pino from "pino";
|
|
5
|
+
|
|
6
|
+
const logger = pino({ name: "executor" });
|
|
7
|
+
|
|
8
|
+
interface MappedAction {
|
|
9
|
+
method: string;
|
|
10
|
+
selector?: string;
|
|
11
|
+
args?: unknown[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function mapActionToPuppeteer(action: LegacyTestAction): MappedAction {
|
|
15
|
+
switch (action.action) {
|
|
16
|
+
case "navigate":
|
|
17
|
+
return {
|
|
18
|
+
method: "goto",
|
|
19
|
+
args: [action.url, { waitUntil: "networkidle0" }],
|
|
20
|
+
};
|
|
21
|
+
case "waitForLoad":
|
|
22
|
+
case "waitForNavigation":
|
|
23
|
+
return {
|
|
24
|
+
method: "waitForNavigation",
|
|
25
|
+
args: [{ waitUntil: "networkidle0", timeout: ACTION_TIMEOUT_MS }],
|
|
26
|
+
};
|
|
27
|
+
case "mouseover":
|
|
28
|
+
return { method: "hover", selector: action.selector };
|
|
29
|
+
case "click":
|
|
30
|
+
return { method: "click", selector: action.selector };
|
|
31
|
+
case "fill":
|
|
32
|
+
return {
|
|
33
|
+
method: "type",
|
|
34
|
+
selector: action.selector,
|
|
35
|
+
args: [action.value],
|
|
36
|
+
};
|
|
37
|
+
case "select":
|
|
38
|
+
return {
|
|
39
|
+
method: "select",
|
|
40
|
+
selector: action.selector,
|
|
41
|
+
args: [action.value],
|
|
42
|
+
};
|
|
43
|
+
case "check":
|
|
44
|
+
case "toggle":
|
|
45
|
+
return { method: "click", selector: action.selector };
|
|
46
|
+
case "scroll":
|
|
47
|
+
return {
|
|
48
|
+
method: "evaluate",
|
|
49
|
+
args: [
|
|
50
|
+
`window.scrollBy(0, ${action.direction === "up" ? -(action.amount || 500) : action.amount || 500})`,
|
|
51
|
+
],
|
|
52
|
+
};
|
|
53
|
+
case "screenshot":
|
|
54
|
+
return { method: "screenshot", args: [{ path: `${action.label}.jpg` }] };
|
|
55
|
+
case "assertVisible":
|
|
56
|
+
return {
|
|
57
|
+
method: "waitForSelector",
|
|
58
|
+
selector: action.selector,
|
|
59
|
+
args: [action.selector, { visible: true, timeout: 5000 }],
|
|
60
|
+
};
|
|
61
|
+
case "assertNotVisible":
|
|
62
|
+
return {
|
|
63
|
+
method: "waitForSelector",
|
|
64
|
+
selector: action.selector,
|
|
65
|
+
args: [action.selector, { hidden: true, timeout: 5000 }],
|
|
66
|
+
};
|
|
67
|
+
case "assertUrl":
|
|
68
|
+
return { method: "assertUrl", args: [action.pattern] };
|
|
69
|
+
case "assertUrlChanged":
|
|
70
|
+
return { method: "assertUrlChanged" };
|
|
71
|
+
case "check_email":
|
|
72
|
+
return { method: "checkEmail", args: [60000] };
|
|
73
|
+
case "step":
|
|
74
|
+
return { method: "noop" };
|
|
75
|
+
default:
|
|
76
|
+
return { method: "noop" };
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export async function executeTestInteraction(
|
|
81
|
+
page: Page,
|
|
82
|
+
actions: LegacyTestAction[]
|
|
83
|
+
): Promise<{ passed: boolean; error?: string; durationMs: number }> {
|
|
84
|
+
const start = Date.now();
|
|
85
|
+
const startUrl = page.url();
|
|
86
|
+
|
|
87
|
+
try {
|
|
88
|
+
for (const action of actions) {
|
|
89
|
+
const mapped = mapActionToPuppeteer(action);
|
|
90
|
+
|
|
91
|
+
switch (mapped.method) {
|
|
92
|
+
case "goto":
|
|
93
|
+
await page.goto(mapped.args![0] as string, mapped.args![1] as any);
|
|
94
|
+
break;
|
|
95
|
+
case "waitForNavigation":
|
|
96
|
+
try {
|
|
97
|
+
await page.waitForNavigation(mapped.args![0] as any);
|
|
98
|
+
} catch (err) {
|
|
99
|
+
logger.debug(
|
|
100
|
+
{ err },
|
|
101
|
+
"navigation timeout during waitForNavigation action"
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
break;
|
|
105
|
+
case "hover": {
|
|
106
|
+
const el = await page.waitForSelector(mapped.selector!, {
|
|
107
|
+
timeout: ACTION_TIMEOUT_MS,
|
|
108
|
+
});
|
|
109
|
+
if (el) await el.hover();
|
|
110
|
+
break;
|
|
111
|
+
}
|
|
112
|
+
case "click": {
|
|
113
|
+
const el = await page.waitForSelector(mapped.selector!, {
|
|
114
|
+
timeout: ACTION_TIMEOUT_MS,
|
|
115
|
+
});
|
|
116
|
+
if (el) await el.click();
|
|
117
|
+
break;
|
|
118
|
+
}
|
|
119
|
+
case "type": {
|
|
120
|
+
const el = await page.waitForSelector(mapped.selector!, {
|
|
121
|
+
timeout: ACTION_TIMEOUT_MS,
|
|
122
|
+
});
|
|
123
|
+
if (el) await el.type(mapped.args![0] as string);
|
|
124
|
+
break;
|
|
125
|
+
}
|
|
126
|
+
case "select":
|
|
127
|
+
await page.select(mapped.selector!, mapped.args![0] as string);
|
|
128
|
+
break;
|
|
129
|
+
case "screenshot":
|
|
130
|
+
await page.screenshot(mapped.args![0] as any);
|
|
131
|
+
break;
|
|
132
|
+
case "waitForSelector":
|
|
133
|
+
await page.waitForSelector(
|
|
134
|
+
mapped.args![0] as string,
|
|
135
|
+
mapped.args![1] as any
|
|
136
|
+
);
|
|
137
|
+
break;
|
|
138
|
+
case "assertUrl": {
|
|
139
|
+
const pattern = mapped.args![0] as string;
|
|
140
|
+
if (!page.url().includes(pattern)) {
|
|
141
|
+
throw new Error(
|
|
142
|
+
`URL assertion failed: expected "${pattern}" in "${page.url()}"`
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
break;
|
|
146
|
+
}
|
|
147
|
+
case "assertUrlChanged":
|
|
148
|
+
if (page.url() === startUrl) {
|
|
149
|
+
throw new Error(`URL did not change from ${startUrl}`);
|
|
150
|
+
}
|
|
151
|
+
break;
|
|
152
|
+
case "evaluate":
|
|
153
|
+
await page.evaluate(mapped.args![0] as string);
|
|
154
|
+
break;
|
|
155
|
+
case "noop":
|
|
156
|
+
break;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return { passed: true, durationMs: Date.now() - start };
|
|
160
|
+
} catch (err: any) {
|
|
161
|
+
return {
|
|
162
|
+
passed: false,
|
|
163
|
+
error: err.message,
|
|
164
|
+
durationMs: Date.now() - start,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export interface RunSummary {
|
|
2
|
+
total: number;
|
|
3
|
+
passed: number;
|
|
4
|
+
failed: number;
|
|
5
|
+
skipped: number;
|
|
6
|
+
durationMs: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function computeSummary(
|
|
10
|
+
results: Array<{ passed: boolean; durationMs: number }>
|
|
11
|
+
): RunSummary {
|
|
12
|
+
return {
|
|
13
|
+
total: results.length,
|
|
14
|
+
passed: results.filter(r => r.passed).length,
|
|
15
|
+
failed: results.filter(r => !r.passed).length,
|
|
16
|
+
skipped: 0,
|
|
17
|
+
durationMs: results.reduce((sum, r) => sum + r.durationMs, 0),
|
|
18
|
+
};
|
|
19
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import type { Browser } from "puppeteer-core";
|
|
2
|
+
import type { LegacyTestAction, Screen } from "@sudobility/testomniac_types";
|
|
3
|
+
import { executeTestInteraction } from "./executor";
|
|
4
|
+
import { getApiClient } from "@sudobility/testomniac_runner_service";
|
|
5
|
+
import pino from "pino";
|
|
6
|
+
|
|
7
|
+
const logger = pino({ name: "worker-pool" });
|
|
8
|
+
|
|
9
|
+
interface TestJob {
|
|
10
|
+
testInteractionId: number;
|
|
11
|
+
runId: number;
|
|
12
|
+
runnerId: number;
|
|
13
|
+
actions: LegacyTestAction[];
|
|
14
|
+
screen: Screen;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface TestJobResult {
|
|
18
|
+
testRunId: number;
|
|
19
|
+
testInteractionId: number;
|
|
20
|
+
screen: string;
|
|
21
|
+
passed: boolean;
|
|
22
|
+
error?: string;
|
|
23
|
+
durationMs: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function runTestJobs(
|
|
27
|
+
browser: Browser,
|
|
28
|
+
jobs: TestJob[],
|
|
29
|
+
concurrency: number = 3
|
|
30
|
+
): Promise<TestJobResult[]> {
|
|
31
|
+
const results: TestJobResult[] = [];
|
|
32
|
+
let jobIndex = 0;
|
|
33
|
+
|
|
34
|
+
const api = getApiClient();
|
|
35
|
+
|
|
36
|
+
async function worker(): Promise<void> {
|
|
37
|
+
while (jobIndex < jobs.length) {
|
|
38
|
+
const job = jobs[jobIndex++];
|
|
39
|
+
if (!job) break;
|
|
40
|
+
|
|
41
|
+
const testInteractionRun = await api.createTestInteractionRun({
|
|
42
|
+
testInteractionId: job.testInteractionId,
|
|
43
|
+
});
|
|
44
|
+
const testRun = await api.createTestRun({
|
|
45
|
+
runnerId: job.runnerId,
|
|
46
|
+
testInteractionRunId: testInteractionRun.id,
|
|
47
|
+
parentTestRunId: job.runId,
|
|
48
|
+
rootTestRunId: job.runId,
|
|
49
|
+
sizeClass: job.screen.name,
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const page = await browser.newPage();
|
|
53
|
+
await page.setViewport({
|
|
54
|
+
width: job.screen.width,
|
|
55
|
+
height: job.screen.height,
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
let result = await executeTestInteraction(page, job.actions);
|
|
59
|
+
|
|
60
|
+
if (!result.passed) {
|
|
61
|
+
logger.info(
|
|
62
|
+
{ testRunId: testRun.id, screen: job.screen.name },
|
|
63
|
+
"retrying failed test"
|
|
64
|
+
);
|
|
65
|
+
result = await executeTestInteraction(page, job.actions);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (!result.passed && result.error) {
|
|
69
|
+
await api.createTestRunFinding({
|
|
70
|
+
testInteractionRunId: testInteractionRun.id,
|
|
71
|
+
type: "error",
|
|
72
|
+
priority: 1,
|
|
73
|
+
title: `Test failed: ${result.error.slice(0, 80)}`,
|
|
74
|
+
description: result.error,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
await api.completeTestInteractionRun(testInteractionRun.id, {
|
|
79
|
+
status: result.passed ? "completed" : "failed",
|
|
80
|
+
durationMs: result.durationMs,
|
|
81
|
+
errorMessage: result.error,
|
|
82
|
+
});
|
|
83
|
+
await api.completeTestRun(testRun.id, {
|
|
84
|
+
status: result.passed ? "completed" : "failed",
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
results.push({
|
|
88
|
+
testRunId: testRun.id,
|
|
89
|
+
testInteractionId: job.testInteractionId,
|
|
90
|
+
screen: job.screen.name,
|
|
91
|
+
passed: result.passed,
|
|
92
|
+
error: result.error,
|
|
93
|
+
durationMs: result.durationMs,
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
await page.close();
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const workers = Array.from(
|
|
101
|
+
{ length: Math.min(concurrency, jobs.length) },
|
|
102
|
+
() => worker()
|
|
103
|
+
);
|
|
104
|
+
await Promise.all(workers);
|
|
105
|
+
return results;
|
|
106
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import pino from "pino";
|
|
2
|
+
import { getApiClient } from "@sudobility/testomniac_runner_service";
|
|
3
|
+
import { loadConfig } from "./config/index";
|
|
4
|
+
import { runFullScan } from "./orchestrator";
|
|
5
|
+
|
|
6
|
+
const logger = pino({
|
|
7
|
+
name: "runner_manager",
|
|
8
|
+
level: process.env.LOG_LEVEL ?? "info",
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
type ActiveRun = {
|
|
12
|
+
runId: number;
|
|
13
|
+
startedAtMs: number;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export class RunnerManager {
|
|
17
|
+
private readonly processInstanceId = crypto.randomUUID();
|
|
18
|
+
private readonly activeRuns = new Map<number, ActiveRun>();
|
|
19
|
+
private tickInFlight = false;
|
|
20
|
+
|
|
21
|
+
constructor(
|
|
22
|
+
private readonly pollIntervalMs: number,
|
|
23
|
+
private readonly maxConcurrentRunners: number
|
|
24
|
+
) {}
|
|
25
|
+
|
|
26
|
+
getActiveRunCount(): number {
|
|
27
|
+
return this.activeRuns.size;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async tick(): Promise<void> {
|
|
31
|
+
if (this.tickInFlight) return;
|
|
32
|
+
|
|
33
|
+
this.tickInFlight = true;
|
|
34
|
+
try {
|
|
35
|
+
const config = loadConfig();
|
|
36
|
+
const api = getApiClient(
|
|
37
|
+
config.apiUrl + "/api/v1/scanner",
|
|
38
|
+
config.scannerApiKey
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
while (this.activeRuns.size < this.maxConcurrentRunners) {
|
|
42
|
+
const pendingRun = await api.getPendingTestRun();
|
|
43
|
+
if (!pendingRun) {
|
|
44
|
+
logger.debug(
|
|
45
|
+
{
|
|
46
|
+
pollIntervalMs: this.pollIntervalMs,
|
|
47
|
+
activeRuns: this.activeRuns.size,
|
|
48
|
+
maxConcurrentRunners: this.maxConcurrentRunners,
|
|
49
|
+
},
|
|
50
|
+
"runner manager heartbeat — no pending runs"
|
|
51
|
+
);
|
|
52
|
+
break;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (this.activeRuns.has(pendingRun.id)) {
|
|
56
|
+
break;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const slotNumber = this.activeRuns.size + 1;
|
|
60
|
+
const runner = await api.getRunner(pendingRun.runnerId);
|
|
61
|
+
if (!runner) {
|
|
62
|
+
logger.error(
|
|
63
|
+
{ runnerId: pendingRun.runnerId, runId: pendingRun.id },
|
|
64
|
+
"runner not found for pending run"
|
|
65
|
+
);
|
|
66
|
+
await api.completeTestRun(pendingRun.id, { status: "failed" });
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const runnerInstanceId = `${this.processInstanceId}:${slotNumber}`;
|
|
71
|
+
const runnerInstanceName = `${runner.title} [slot ${slotNumber}]`;
|
|
72
|
+
const claimed = await api.claimTestRun(
|
|
73
|
+
pendingRun.id,
|
|
74
|
+
runnerInstanceId,
|
|
75
|
+
runnerInstanceName
|
|
76
|
+
);
|
|
77
|
+
if (!claimed) {
|
|
78
|
+
logger.info(
|
|
79
|
+
{ runId: pendingRun.id },
|
|
80
|
+
"pending run was claimed by another runner before launch"
|
|
81
|
+
);
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
this.activeRuns.set(pendingRun.id, {
|
|
86
|
+
runId: pendingRun.id,
|
|
87
|
+
startedAtMs: Date.now(),
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
logger.info(
|
|
91
|
+
{
|
|
92
|
+
runId: pendingRun.id,
|
|
93
|
+
slotNumber,
|
|
94
|
+
activeRuns: this.activeRuns.size,
|
|
95
|
+
maxConcurrentRunners: this.maxConcurrentRunners,
|
|
96
|
+
},
|
|
97
|
+
"starting claimed run"
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
void this.runClaimedRun({
|
|
101
|
+
runnerId: runner.id,
|
|
102
|
+
runId: pendingRun.id,
|
|
103
|
+
scanUrl: pendingRun.scanUrl ?? "",
|
|
104
|
+
sizeClass: pendingRun.sizeClass,
|
|
105
|
+
runnerName: runner.title,
|
|
106
|
+
runnerInstanceId,
|
|
107
|
+
runnerInstanceName,
|
|
108
|
+
quickScan: pendingRun.quickScan ?? false,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
} finally {
|
|
112
|
+
this.tickInFlight = false;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
private async runClaimedRun(params: {
|
|
117
|
+
runnerId: number;
|
|
118
|
+
runId: number;
|
|
119
|
+
scanUrl: string;
|
|
120
|
+
sizeClass: string;
|
|
121
|
+
runnerName: string;
|
|
122
|
+
runnerInstanceId: string;
|
|
123
|
+
runnerInstanceName: string;
|
|
124
|
+
quickScan: boolean;
|
|
125
|
+
}): Promise<void> {
|
|
126
|
+
const config = loadConfig();
|
|
127
|
+
const api = getApiClient(
|
|
128
|
+
config.apiUrl + "/api/v1/scanner",
|
|
129
|
+
config.scannerApiKey
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
try {
|
|
133
|
+
await runFullScan({
|
|
134
|
+
runnerId: params.runnerId,
|
|
135
|
+
scanId: params.runId,
|
|
136
|
+
scanUrl: params.scanUrl,
|
|
137
|
+
baseUrl: params.scanUrl,
|
|
138
|
+
sizeClass: params.sizeClass,
|
|
139
|
+
runnerName: params.runnerName,
|
|
140
|
+
runnerInstanceId: params.runnerInstanceId,
|
|
141
|
+
runnerInstanceName: params.runnerInstanceName,
|
|
142
|
+
quickScan: params.quickScan,
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
logger.info({ runId: params.runId }, "run completed successfully");
|
|
146
|
+
} catch (error) {
|
|
147
|
+
logger.error({ err: error, runId: params.runId }, "claimed run failed");
|
|
148
|
+
try {
|
|
149
|
+
await api.completeTestRun(params.runId, {
|
|
150
|
+
status: "failed",
|
|
151
|
+
});
|
|
152
|
+
} catch (completeError) {
|
|
153
|
+
logger.error(
|
|
154
|
+
{ err: completeError, runId: params.runId },
|
|
155
|
+
"failed to mark claimed run as failed"
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
} finally {
|
|
159
|
+
this.activeRuns.delete(params.runId);
|
|
160
|
+
void this.tick();
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { SignicClient } from "@sudobility/signic_sdk";
|
|
2
|
+
import type { Page } from "puppeteer-core";
|
|
3
|
+
import pino from "pino";
|
|
4
|
+
import {
|
|
5
|
+
EMAIL_CHECK_TIMEOUT_MS,
|
|
6
|
+
EMAIL_CHECK_INTERVAL_MS,
|
|
7
|
+
} from "@sudobility/testomniac_runner_service";
|
|
8
|
+
|
|
9
|
+
const logger = pino({ name: "email-checker" });
|
|
10
|
+
|
|
11
|
+
export interface EmailCheckResult {
|
|
12
|
+
received: boolean;
|
|
13
|
+
otp?: string;
|
|
14
|
+
link?: string;
|
|
15
|
+
rawText?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function checkEmail(
|
|
19
|
+
client: SignicClient
|
|
20
|
+
): Promise<EmailCheckResult> {
|
|
21
|
+
const deadline = Date.now() + EMAIL_CHECK_TIMEOUT_MS;
|
|
22
|
+
|
|
23
|
+
while (Date.now() < deadline) {
|
|
24
|
+
const { emails } = await client.getUnreadEmails(5);
|
|
25
|
+
if (emails.length > 0) {
|
|
26
|
+
const full = await client.getEmail(emails[0].id);
|
|
27
|
+
await client.markAsRead(emails[0].id);
|
|
28
|
+
const body = full.text || full.html?.join("") || "";
|
|
29
|
+
|
|
30
|
+
const otpMatch = body.match(/\b(\d{4,8})\b/);
|
|
31
|
+
const linkMatch = body.match(
|
|
32
|
+
/https?:\/\/[^\s"'<>]+(verify|confirm|activate|reset)[^\s"'<>]*/i
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
logger.info(
|
|
36
|
+
{ hasOtp: !!otpMatch, hasLink: !!linkMatch, subject: full.subject },
|
|
37
|
+
"email received"
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
received: true,
|
|
42
|
+
otp: otpMatch?.[1],
|
|
43
|
+
link: linkMatch?.[0],
|
|
44
|
+
rawText: body.slice(0, 500),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
await new Promise(r => setTimeout(r, EMAIL_CHECK_INTERVAL_MS));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
logger.warn("email check timed out");
|
|
51
|
+
return { received: false };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function actOnEmail(
|
|
55
|
+
page: Page,
|
|
56
|
+
result: EmailCheckResult
|
|
57
|
+
): Promise<boolean> {
|
|
58
|
+
if (!result.received) return false;
|
|
59
|
+
|
|
60
|
+
if (result.otp) {
|
|
61
|
+
const otpSelectors = [
|
|
62
|
+
'input[name*="code"]',
|
|
63
|
+
'input[name*="otp"]',
|
|
64
|
+
'input[name*="verification"]',
|
|
65
|
+
'input[placeholder*="code"]',
|
|
66
|
+
'input[type="number"]',
|
|
67
|
+
];
|
|
68
|
+
for (const sel of otpSelectors) {
|
|
69
|
+
try {
|
|
70
|
+
const el = await page.waitForSelector(sel, {
|
|
71
|
+
timeout: 3000,
|
|
72
|
+
visible: true,
|
|
73
|
+
});
|
|
74
|
+
if (el) {
|
|
75
|
+
await el.type(result.otp);
|
|
76
|
+
await page.keyboard.press("Enter");
|
|
77
|
+
try {
|
|
78
|
+
await page.waitForNavigation({
|
|
79
|
+
waitUntil: "networkidle0",
|
|
80
|
+
timeout: 10_000,
|
|
81
|
+
});
|
|
82
|
+
} catch (err) {
|
|
83
|
+
logger.debug(
|
|
84
|
+
{ err },
|
|
85
|
+
"navigation timeout during email verification"
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
} catch (err) {
|
|
91
|
+
logger.debug({ err, selector: sel }, "OTP field selector not found");
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (result.link) {
|
|
98
|
+
await page.goto(result.link, {
|
|
99
|
+
waitUntil: "networkidle0",
|
|
100
|
+
timeout: 15_000,
|
|
101
|
+
});
|
|
102
|
+
return true;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return false;
|
|
106
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ESNext",
|
|
4
|
+
"module": "Preserve",
|
|
5
|
+
"moduleResolution": "bundler",
|
|
6
|
+
"lib": ["ESNext", "DOM"],
|
|
7
|
+
"outDir": "./dist",
|
|
8
|
+
"rootDir": "./src",
|
|
9
|
+
"strict": true,
|
|
10
|
+
"esModuleInterop": true,
|
|
11
|
+
"skipLibCheck": true,
|
|
12
|
+
"forceConsistentCasingInFileNames": true,
|
|
13
|
+
"resolveJsonModule": true,
|
|
14
|
+
"isolatedModules": true,
|
|
15
|
+
"noEmit": true,
|
|
16
|
+
"allowSyntheticDefaultImports": true,
|
|
17
|
+
"types": ["node"]
|
|
18
|
+
},
|
|
19
|
+
"include": ["src/**/*"],
|
|
20
|
+
"exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts", "scripts/**"]
|
|
21
|
+
}
|