@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.
Files changed (60) hide show
  1. package/.dockerignore +75 -0
  2. package/.env.example +67 -0
  3. package/.github/workflows/ci-cd.yml +30 -0
  4. package/.prettierignore +62 -0
  5. package/.prettierrc +11 -0
  6. package/.vscode/settings.json +29 -0
  7. package/CLAUDE.md +170 -0
  8. package/Dockerfile +76 -0
  9. package/README.md +22 -0
  10. package/bun.lock +707 -0
  11. package/docs/superpowers/specs/2026-04-20-smarter-scanner-navigation-design.md +121 -0
  12. package/eslint.config.js +80 -0
  13. package/package.json +55 -0
  14. package/plans/DATA.md +703 -0
  15. package/plans/POLLING.md +569 -0
  16. package/plans/RUNNER.md +288 -0
  17. package/src/adapters/PuppeteerAdapter.ts +394 -0
  18. package/src/auth/credential-manager.ts +17 -0
  19. package/src/auth/form-identifier.test.ts +136 -0
  20. package/src/auth/form-identifier.ts +54 -0
  21. package/src/auth/login-executor.ts +112 -0
  22. package/src/auth/password-detector.test.ts +61 -0
  23. package/src/auth/password-detector.ts +119 -0
  24. package/src/auth/signic-registrar.ts +186 -0
  25. package/src/browser/chromium.ts +35 -0
  26. package/src/config/index.test.ts +23 -0
  27. package/src/config/index.ts +35 -0
  28. package/src/email/deep-link.test.ts +17 -0
  29. package/src/email/deep-link.ts +23 -0
  30. package/src/email/sender.ts +35 -0
  31. package/src/email/templates.ts +34 -0
  32. package/src/index.test.ts +17 -0
  33. package/src/index.ts +110 -0
  34. package/src/orchestrator.ts +220 -0
  35. package/src/plugins/content/ai-checks.ts +115 -0
  36. package/src/plugins/content/checks.test.ts +49 -0
  37. package/src/plugins/content/checks.ts +141 -0
  38. package/src/plugins/content/index.ts +73 -0
  39. package/src/plugins/registry.test.ts +49 -0
  40. package/src/plugins/registry.ts +21 -0
  41. package/src/plugins/security/header-checks.ts +56 -0
  42. package/src/plugins/security/html-checks.ts +93 -0
  43. package/src/plugins/security/index.ts +58 -0
  44. package/src/plugins/security/network-checks.test.ts +74 -0
  45. package/src/plugins/security/network-checks.ts +136 -0
  46. package/src/plugins/seo/checks.test.ts +70 -0
  47. package/src/plugins/seo/checks.ts +173 -0
  48. package/src/plugins/seo/index.ts +85 -0
  49. package/src/plugins/types.ts +43 -0
  50. package/src/plugins/ui-consistency/comparator.test.ts +108 -0
  51. package/src/plugins/ui-consistency/comparator.ts +58 -0
  52. package/src/plugins/ui-consistency/index.ts +36 -0
  53. package/src/plugins/ui-consistency/style-extractor.ts +79 -0
  54. package/src/runner/executor.test.ts +37 -0
  55. package/src/runner/executor.ts +167 -0
  56. package/src/runner/reporter.ts +19 -0
  57. package/src/runner/worker-pool.ts +106 -0
  58. package/src/runner-manager.ts +163 -0
  59. package/src/scanner/email-checker.ts +106 -0
  60. package/tsconfig.json +21 -0
@@ -0,0 +1,288 @@
1
+ # Runner Redesign Spec
2
+
3
+ ## Architecture
4
+
5
+ 1. **testomniac_runner_service** is the shared code.
6
+ 2. **testomniac_runner** is the backend worker.
7
+ 3. **testomniac_extension** is the frontend Chrome extension.
8
+
9
+ - testomniac_extension is designed so developers can run local deployment in addition to dev, qa, staging, and production environments with visual feedback.
10
+ - testomniac_runner cannot run local deployment.
11
+ - Each run has to specify the environment (via `testEnvironmentId` FK to `test_environments`), and it has a "discovery" flag. A discovery flag indicates whether the runner should automatically find new test suite/test cases while running. If discovery is set to false, the runner should only run the tests and report results.
12
+ - A new scan always has discovery flag set to true. It can be started on the website, which will be run by testomniac_runner. If it is started in the extension, it should be run by testomniac_extension.
13
+
14
+ ## Terminology
15
+
16
+ **Ensure** — Find an existing record in the database. If not found, create a new one and persist it. All "ensure" operations are idempotent.
17
+
18
+ **Ensure lookup keys:**
19
+
20
+ - Test Suite Bundle: `(runnerId, title, uid)`
21
+ - Test Suite: `(runnerId, title, uid)`
22
+ - Test Case: `(testSuiteId, startingPath or action target)`
23
+
24
+ ## Scan Startup Flow
25
+
26
+ Test case, test suite, and test suite bundle all should have a `uid` field (user firebase uid), even though the product already has an entity id. The reason is, we allow developers to run their own tests. Different environments may cause different behaviors.
27
+
28
+ When a developer inputs a new URL in the extension, the developer's uid is used. If they input a new URL on the web site, the uid is not set.
29
+
30
+ When starting a scan:
31
+
32
+ 1. Ensure a "Complete Test" test suite bundle under the runner (matching uid).
33
+ 2. Ensure a "Navigation" test suite under the runner (matching uid). Add the navigation test suite to the Complete Test bundle.
34
+ 3. In the navigation test suite, ensure a test case to navigate to the URL.
35
+ 4. Create a test suite bundle run for the Complete Test bundle.
36
+ 5. Create a test run object pointing to the test suite bundle run.
37
+
38
+ In testomniac_extension, invoke the runner with the test run object.
39
+
40
+ In testomniac_runner, it periodically checks if there is a test run object.
41
+
42
+ Each runner should have a random UUID and a name. The runner ID is set in the run object, so we don't have multiple runners on the same run. If a test run already has a `runnerInstanceId` set, other runners skip it.
43
+
44
+ ## Runner Execution Logic
45
+
46
+ > This replaces the existing orchestrator (`runScan`, `processDecompositionJob`, `executeTestCases`) entirely.
47
+
48
+ The runner keeps track of three pointers:
49
+
50
+ - **Current test suite bundle** — the bundle being executed
51
+ - **Current test suite** — the suite being executed within the bundle
52
+ - **Current test case** — the case being executed within the suite
53
+
54
+ When the runner invokes test functions for test suite bundle, test suite, or test case, always pass in the run object and a list of expertises.
55
+
56
+ ### Execution loop
57
+
58
+ 1. Set the current test suite bundle from the run object.
59
+ 2. Pick the first open test suite in the bundle. Set it as current test suite.
60
+ 3. Pick the first open test case in the current test suite. Set it as current test case.
61
+ 4. Run the current test case (see "Running a Test Case" below).
62
+ 5. After the test case completes, search for the next open test case in the current test suite. If found, go to step 4.
63
+ 6. When all test cases in the current test suite are done, mark the test suite run as completed. Search for the next open test suite in the bundle. If found, go to step 3.
64
+ 7. When all test suites (and their test cases) in the bundle are done, mark the test suite bundle run as completed. Mark the test run as completed.
65
+
66
+ Note: In discovery mode, the analyzer may add new test suites to the bundle and new test cases to suites during execution. The runner picks these up naturally because it searches for "open" items at each step.
67
+
68
+ ## Running a Test Case (discovery = false)
69
+
70
+ Run the test case. If the test run object discovery flag is false, it uses expertises to verify the result. First, it calls the existing local decomposition (component-detector for scaffolds, pattern-detector for patterns, html-decomposer for stripping) to break down the page, and passes the following to each expertise to verify:
71
+
72
+ 1. Full HTML (including header meta tags)
73
+ 2. Decomposed component lists
74
+ 3. Console log during the test case run
75
+ 4. Network log during the test case run
76
+
77
+ ### Test Case Expectations
78
+
79
+ Each test case is associated with a list of expectations.
80
+
81
+ ### Outcome Model
82
+
83
+ In-memory outcome object:
84
+ - `expected: string` — what was expected
85
+ - `observed: string` — what was actually observed
86
+ - `result: 'pass' | 'warning' | 'error'`
87
+
88
+ Each expertise returns a list of outcomes.
89
+
90
+ ### Expertises
91
+
92
+ All expertises are written fresh in `testomniac_runner_service` (not reusing the old plugin code in `testomniac_runner`). All expertises create `TestRunFinding` records in the database for both warnings and errors.
93
+
94
+ 1. **Tester**: Checks each test case expectation is met. If expectation is not met, creates error finding.
95
+ 2. **SEO**: Checks if the meta tags are present: title, keywords, etc. Creates findings for missing tags.
96
+ 3. **Security**: Checks if network calls contain insecure practices, such as API keys. Creates findings for violations.
97
+ 4. **Content**: No-op for now. Returns empty outcomes list.
98
+ 5. **UI**: No-op for now. Returns empty outcomes list.
99
+ 6. **Accessibility**: No-op for now. Returns empty outcomes list.
100
+ 7. **Performance**: Checks network log and makes sure all content for rendering is returned within certain time (such as 0.5 second). Creates findings for slow resources.
101
+
102
+ ### Setting Results
103
+
104
+ With the outcomes list from each expertise, the runner sets the expected outcome and observed outcome in the test case run, and sets the status of the test case run accordingly. If test case has warnings or errors, it should have HTML, console log, and network log so the developer can get all the info needed to fix the issue.
105
+
106
+ ## Running a Test Case (discovery = true)
107
+
108
+ If the discovery flag is set to true, we need to use other in-memory singleton objects: **Page Analyzer**.
109
+
110
+ ### Page Analyzer Functions
111
+
112
+ 1. **generateExpectations(testCase)** — Returns a list of test case expectations.
113
+ - It expects the result to be HTML, with no HTTP error.
114
+ - If the test case has just a navigation action, no more expectations.
115
+ - This is called **before** the expertises are called to generate outcomes. Basically, the analyzer generates a simple test expectation that the HTML loads. The tester verifies it.
116
+
117
+ 2. **generateTestCases(...)** — Called **after** the expertises are called and test case run's expected outcome, observed outcome, and status are set.
118
+
119
+ **Mouse-over fixup:** If the test case has only a mouse-over action, compare the current page state against the beginning page state. If they are the same (the hover had no visible effect), add a mouse click action to the test case.
120
+
121
+ **Then generate new test cases:**
122
+
123
+ When the analyzer ensures a test suite into the Complete Test bundle, it also creates a test suite run under the current bundle run. When it ensures a test case, it also creates a test case run under the corresponding test suite run. This way the execution loop finds them as "open" items immediately.
124
+
125
+ a. **Navigation test cases** — For every link detected on the page, if the current page does not require login, ensure a test case with a navigate action to that link's URL and put it into the Navigation test suite. The Navigation test suite is added to the Complete Test bundle.
126
+
127
+ b. **Scaffold test cases** — Loop through each scaffold detected on the page. For each scaffold, ensure a test suite for that scaffold. Within the scaffold, iterate through any elements that can take a mouse action. For each such element, ensure a test case with a mouse-over action targeting that element and add it to the scaffold's test suite. Add the scaffold's test suite to the Complete Test bundle.
128
+
129
+ c. **Content test cases** — Loop through the page content (non-scaffold areas). Ensure a test suite for this page. Iterate through any elements that can take a mouse action. For each such element, ensure a test case with a mouse-over action targeting that element and add it to the page's test suite. Add the page's test suite to the Complete Test bundle.
130
+
131
+ ## Data
132
+
133
+ ### Persisted Objects (Database)
134
+
135
+ **Product** (existing)
136
+ - Has an entity id (organization-level ownership)
137
+ - Parent of runners, test suites, test suite bundles
138
+
139
+ **Runner** (existing)
140
+ - Belongs to a product
141
+ - Represents a target application to test
142
+
143
+ **Test Suite Bundle**
144
+ - Belongs to a runner
145
+ - `uid?: string` — firebase user id (new). Set when created by a developer in the extension; null when created from the website.
146
+ - Contains an ordered list of test suites (via junction table)
147
+
148
+ **Test Suite**
149
+ - Belongs to a runner
150
+ - `uid?: string` — firebase user id (new). Set when created by a developer in the extension; null when created from the website.
151
+ - Has a starting page state and starting path
152
+ - Has a size class, priority, suite tags
153
+ - May be linked to a scaffold (scaffoldId, scaffoldType) or a pattern (patternType)
154
+ - Contains test cases
155
+
156
+ **Test Case**
157
+ - Belongs to a runner and a test suite
158
+ - `uid?: string` — firebase user id (new). Set when created by a developer in the extension; null when created from the website.
159
+ - Has a type (render, interaction, form, navigation, e2e, password)
160
+ - Has steps (TestStep[]), each step has an action and expectations
161
+ - Has global expectations (Expectation[])
162
+ - May be linked to a scaffold, pattern, page, persona, or use case
163
+
164
+ **Expectation** (embedded in test case steps and global expectations)
165
+ - `expectationType` — what to check (e.g., page_loads, element_visible, no_console_errors, etc.)
166
+ - `expectedValue?` — the expected value
167
+ - `severity` — 'must_pass' | 'should_pass' | 'info'
168
+ - `description` — human-readable description
169
+ - `playwrightCode` — code to evaluate the expectation
170
+
171
+ **Test Suite Bundle Run**
172
+ - Belongs to a test suite bundle
173
+ - Tracks execution status of a bundle
174
+ - Has status, startedAt, completedAt
175
+
176
+ **Test Suite Run**
177
+ - Belongs to a test suite
178
+ - May belong to a test suite bundle run
179
+ - Tracks execution status of a suite
180
+ - Has status, startedAt, completedAt
181
+
182
+ **Test Case Run**
183
+ - Belongs to a test case
184
+ - May belong to a test suite run
185
+ - Has status, durationMs, errorMessage
186
+ - `expectedOutcome?: string` (new) — aggregated expected outcomes from expertises
187
+ - `observedOutcome?: string` (new) — aggregated observed outcomes from expertises
188
+ - `screenshotPath?` — screenshot if warnings/errors
189
+ - `consoleLog?` — console log if warnings/errors
190
+ - `networkLog?` — network log if warnings/errors
191
+ - Has startedAt, completedAt
192
+
193
+ **Test Run**
194
+ - Belongs to a runner
195
+ - Points to exactly one of: test case run, test suite run, or test suite bundle run
196
+ - `discovery: boolean` — whether to auto-discover new test suites/cases
197
+ - `createdByUserId?` — who started the run
198
+ - `ownedByUserId?` — who owns the run
199
+ - `runnerInstanceId?: string` (new) — UUID of the runner instance executing this run
200
+ - `runnerInstanceName?: string` (new) — human-readable name of the runner instance
201
+ - Has status, scanUrl, sizeClass, stats (pagesFound, pageStatesFound, etc.)
202
+
203
+ **Test Run Finding**
204
+ - Belongs to a test case run
205
+ - May be linked to an expertise rule
206
+ - Has type ('error' | 'warning'), title, description
207
+
208
+ **Page** (existing)
209
+ - Belongs to a runner
210
+ - Represents a URL path
211
+
212
+ **Page State** (existing)
213
+ - Belongs to a page
214
+ - Represents a snapshot of the page at a point in time
215
+ - Has HTML hashes, scaffold hash, pattern hash, screenshot, console/network logs
216
+
217
+ **Scaffold** (existing)
218
+ - Belongs to a runner
219
+ - Detected reusable UI region (header, footer, nav, sidebar, etc.)
220
+ - Linked to page states via junction table
221
+
222
+ **Page State Pattern** (existing)
223
+ - Belongs to a page state
224
+ - Detected UI pattern instance (card, table, form, modal, etc.)
225
+
226
+ ### In-Memory Objects (Runner Service)
227
+
228
+ **Outcome**
229
+ - `expected: string` — what was expected
230
+ - `observed: string` — what was actually observed
231
+ - `result: 'pass' | 'warning' | 'error'`
232
+ - Produced by expertises, consumed by the runner to set test case run results
233
+
234
+ **Expertise** (interface)
235
+ - `name: string` — expertise identifier
236
+ - `evaluate(context: ExpertiseContext): Outcome[]` — produces outcomes
237
+ - Implementations: Tester, SEO, Security, Content (no-op), UI (no-op), Accessibility (no-op), Performance
238
+
239
+ **ExpertiseContext** (passed to each expertise)
240
+ - `html: string` — full page HTML including head/meta tags
241
+ - `scaffolds: DetectedScaffoldRegion[]` — decomposed scaffold list
242
+ - `patterns: DetectedPatternWithInstances[]` — decomposed pattern list
243
+ - `consoleLogs: string[]` — console output captured during test case execution
244
+ - `networkLogs: NetworkLogEntry[]` — network requests/responses captured during execution
245
+ - `expectations: Expectation[]` — the test case's expectations (used by Tester expertise)
246
+
247
+ **PageAnalyzer** (singleton)
248
+ - `generateExpectations(testCase): Expectation[]` — generates baseline expectations before expertise evaluation
249
+ - `generateTestCases(...)` — generates new test cases for scaffolds/patterns after expertise evaluation (discovery mode only)
250
+
251
+ **Runner Instance**
252
+ - `id: string` — random UUID, generated at startup
253
+ - `name: string` — human-readable identifier
254
+ - Set on the test run object to claim it and prevent double-execution
255
+
256
+ ### Relationships
257
+
258
+ ```
259
+ Product
260
+ └── Runner
261
+ ├── Test Suite Bundle ──[uid?]
262
+ │ └── Test Suite Bundle ↔ Test Suite (junction, ordered)
263
+ ├── Test Suite ──[uid?]
264
+ │ └── Test Case ──[uid?]
265
+ │ ├── TestStep[] (embedded JSON)
266
+ │ │ ├── TestAction
267
+ │ │ └── Expectation[]
268
+ │ └── Expectation[] (global)
269
+ ├── Scaffold
270
+ │ └── Page State ↔ Scaffold (junction)
271
+ └── Page
272
+ └── Page State
273
+ └── Page State Pattern
274
+
275
+ Test Suite Bundle Run ──→ Test Suite Bundle
276
+ └── Test Suite Run ──→ Test Suite
277
+ └── Test Case Run ──→ Test Case
278
+ └── Test Run Finding
279
+
280
+ Test Run ──→ Runner
281
+ ├── → Test Case Run (optional)
282
+ ├── → Test Suite Run (optional)
283
+ └── → Test Suite Bundle Run (optional)
284
+
285
+ Expertise ──evaluates──→ ExpertiseContext ──produces──→ Outcome[]
286
+ PageAnalyzer ──generates──→ Expectation[] (before expertises)
287
+ PageAnalyzer ──generates──→ Test Cases (after expertises, discovery only)
288
+ ```
@@ -0,0 +1,394 @@
1
+ import type { Page } from "puppeteer-core";
2
+ import type { BrowserAdapter } from "@sudobility/testomniac_runner_service";
3
+ import pino from "pino";
4
+
5
+ const logger = pino({ name: "puppeteer-adapter" });
6
+
7
+ const REPLAY_SELECTOR_PREFIX = "tmnc-replay:";
8
+
9
+ export class PuppeteerAdapter implements BrowserAdapter {
10
+ private page: Page;
11
+ private currentUrl: string = "";
12
+ private consoleLogBuffer: string[] = [];
13
+ private networkLogBuffer: Array<{
14
+ method: string;
15
+ url: string;
16
+ status: number;
17
+ contentType: string;
18
+ }> = [];
19
+
20
+ constructor(page: Page) {
21
+ this.page = page;
22
+ }
23
+
24
+ private async materializeSelector(selector: string): Promise<string> {
25
+ if (!selector.startsWith(REPLAY_SELECTOR_PREFIX)) {
26
+ return selector;
27
+ }
28
+
29
+ const token = `tmnc-replay-${Date.now()}-${Math.random()
30
+ .toString(36)
31
+ .slice(2, 8)}`;
32
+ return this.page.evaluate(
33
+ (rawSelector: string, replayToken: string, prefix: string) => {
34
+ const normalize = (value: string | null | undefined) =>
35
+ (value ?? "").replace(/\s+/g, " ").trim().toLowerCase();
36
+ const isVisible = (el: Element) => {
37
+ if (!(el instanceof HTMLElement)) return false;
38
+ const rect = el.getBoundingClientRect();
39
+ return !el.hidden && rect.width > 0 && rect.height > 0;
40
+ };
41
+ const params = new URLSearchParams(rawSelector.slice(prefix.length));
42
+ const spec = {
43
+ css: params.get("css")?.trim() || "",
44
+ tagName: params.get("tagName")?.trim() || "",
45
+ role: params.get("role")?.trim() || "",
46
+ inputType: params.get("inputType")?.trim() || "",
47
+ accessibleName: params.get("accessibleName")?.trim() || "",
48
+ textContent: params.get("textContent")?.trim() || "",
49
+ href: params.get("href")?.trim() || "",
50
+ testId: params.get("testId")?.trim() || "",
51
+ id: params.get("id")?.trim() || "",
52
+ name: params.get("name")?.trim() || "",
53
+ placeholder: params.get("placeholder")?.trim() || "",
54
+ };
55
+ const mark = (el: Element | null) => {
56
+ if (!(el instanceof HTMLElement)) return rawSelector;
57
+ el.setAttribute("data-tmnc-replay-target", replayToken);
58
+ return `[data-tmnc-replay-target="${replayToken}"]`;
59
+ };
60
+
61
+ if (spec.css) {
62
+ const match = document.querySelector(spec.css);
63
+ if (match && isVisible(match)) return mark(match);
64
+ }
65
+
66
+ for (const testSelector of spec.testId
67
+ ? [
68
+ `[data-testid="${spec.testId}"]`,
69
+ `[data-test-id="${spec.testId}"]`,
70
+ `[data-test="${spec.testId}"]`,
71
+ ]
72
+ : []) {
73
+ const match = document.querySelector(testSelector);
74
+ if (match && isVisible(match)) return mark(match);
75
+ }
76
+
77
+ if (spec.id) {
78
+ const match = document.getElementById(spec.id);
79
+ if (match && isVisible(match)) return mark(match);
80
+ }
81
+
82
+ const candidates = Array.from(
83
+ document.querySelectorAll(spec.tagName || "*")
84
+ );
85
+ const match = candidates.find(candidate => {
86
+ if (!isVisible(candidate)) return false;
87
+ if (
88
+ spec.role &&
89
+ normalize(candidate.getAttribute("role")) !== normalize(spec.role)
90
+ ) {
91
+ const tagName = candidate.tagName.toLowerCase();
92
+ const roleMatchesImplicitTag =
93
+ (spec.role === "link" && tagName === "a") ||
94
+ (spec.role === "button" && tagName === "button");
95
+ if (!roleMatchesImplicitTag) {
96
+ return false;
97
+ }
98
+ }
99
+ if (
100
+ spec.inputType &&
101
+ normalize((candidate as HTMLInputElement).type) !==
102
+ normalize(spec.inputType)
103
+ ) {
104
+ return false;
105
+ }
106
+ if (
107
+ spec.href &&
108
+ normalize(candidate.getAttribute("href")) !== normalize(spec.href)
109
+ ) {
110
+ return false;
111
+ }
112
+ if (
113
+ spec.name &&
114
+ normalize(candidate.getAttribute("name")) !== normalize(spec.name)
115
+ ) {
116
+ return false;
117
+ }
118
+ if (
119
+ spec.placeholder &&
120
+ normalize(candidate.getAttribute("placeholder")) !==
121
+ normalize(spec.placeholder)
122
+ ) {
123
+ return false;
124
+ }
125
+
126
+ const candidateName = normalize(
127
+ candidate.getAttribute("aria-label") || candidate.textContent
128
+ );
129
+ const expectedNames = [
130
+ normalize(spec.accessibleName),
131
+ normalize(spec.textContent),
132
+ ].filter(Boolean);
133
+ return (
134
+ expectedNames.length === 0 ||
135
+ expectedNames.some(
136
+ expected =>
137
+ candidateName === expected || candidateName.includes(expected)
138
+ )
139
+ );
140
+ });
141
+
142
+ return mark(match ?? null);
143
+ },
144
+ selector,
145
+ token,
146
+ REPLAY_SELECTOR_PREFIX
147
+ );
148
+ }
149
+
150
+ async goto(
151
+ url: string,
152
+ options?: { waitUntil?: string; timeout?: number }
153
+ ): Promise<void> {
154
+ await this.page.goto(url, {
155
+ waitUntil: (options?.waitUntil as any) || "networkidle0",
156
+ timeout: options?.timeout || 30000,
157
+ });
158
+ this.currentUrl = this.page.url();
159
+ }
160
+
161
+ async click(selector: string, options?: { timeout?: number }): Promise<void> {
162
+ const resolvedSelector = await this.materializeSelector(selector);
163
+ const el = await this.page.waitForSelector(resolvedSelector, {
164
+ timeout: options?.timeout || 5000,
165
+ visible: true,
166
+ });
167
+ if (el) await el.click();
168
+ }
169
+
170
+ async hover(selector: string, options?: { timeout?: number }): Promise<void> {
171
+ const resolvedSelector = await this.materializeSelector(selector);
172
+ const el = await this.page.waitForSelector(resolvedSelector, {
173
+ timeout: options?.timeout || 5000,
174
+ visible: true,
175
+ });
176
+ if (el) await el.hover();
177
+ }
178
+
179
+ async type(selector: string, text: string): Promise<void> {
180
+ const resolvedSelector = await this.materializeSelector(selector);
181
+ const el = await this.page.waitForSelector(resolvedSelector, {
182
+ timeout: 5000,
183
+ });
184
+ if (el) {
185
+ await el.click({ clickCount: 3 });
186
+ await el.type(text);
187
+ }
188
+ }
189
+
190
+ async waitForSelector(
191
+ selector: string,
192
+ options?: { visible?: boolean; timeout?: number }
193
+ ): Promise<boolean> {
194
+ try {
195
+ const resolvedSelector = await this.materializeSelector(selector);
196
+ await this.page.waitForSelector(resolvedSelector, {
197
+ visible: options?.visible,
198
+ timeout: options?.timeout || 5000,
199
+ });
200
+ return true;
201
+ } catch (err) {
202
+ logger.debug({ err, selector }, "waitForSelector timeout");
203
+ return false;
204
+ }
205
+ }
206
+
207
+ async waitForNavigation(options?: {
208
+ waitUntil?: string;
209
+ timeout?: number;
210
+ }): Promise<void> {
211
+ try {
212
+ await this.page.waitForNavigation({
213
+ waitUntil: (options?.waitUntil as any) || "networkidle0",
214
+ timeout: options?.timeout || 5000,
215
+ });
216
+ } catch (err) {
217
+ logger.debug({ err }, "navigation timeout — may not occur");
218
+ }
219
+ this.currentUrl = this.page.url();
220
+ }
221
+
222
+ async evaluate<T>(
223
+ fn: string | ((...args: unknown[]) => T),
224
+ ...args: unknown[]
225
+ ): Promise<T> {
226
+ if (typeof fn === "string") {
227
+ return this.page.evaluate(fn) as Promise<T>;
228
+ }
229
+ return this.page.evaluate(fn, ...args) as Promise<T>;
230
+ }
231
+
232
+ async content(): Promise<string> {
233
+ return this.page.content();
234
+ }
235
+
236
+ url(): string {
237
+ return this.page.url();
238
+ }
239
+
240
+ async screenshot(options?: {
241
+ type?: string;
242
+ quality?: number;
243
+ }): Promise<Uint8Array> {
244
+ const buffer = await this.page.screenshot({
245
+ type: (options?.type as "jpeg" | "png") || "jpeg",
246
+ quality: options?.quality || 72,
247
+ });
248
+ return new Uint8Array(buffer);
249
+ }
250
+
251
+ async setViewport(width: number, height: number): Promise<void> {
252
+ await this.page.setViewport({ width, height });
253
+ }
254
+
255
+ async pressKey(key: string): Promise<void> {
256
+ await this.page.keyboard.press(key as any);
257
+ }
258
+
259
+ async select(selector: string, value: string): Promise<void> {
260
+ const resolvedSelector = await this.materializeSelector(selector);
261
+ await this.page.select(resolvedSelector, value);
262
+ }
263
+
264
+ async close(): Promise<void> {
265
+ await this.page.close();
266
+ }
267
+
268
+ on(
269
+ event: "console" | "response",
270
+ handler: (...args: unknown[]) => void
271
+ ): () => void {
272
+ if (event === "console") {
273
+ const wrapped = (msg: { type(): string; text(): string }) => {
274
+ const message = `${msg.type()}: ${msg.text()}`;
275
+ this.consoleLogBuffer.push(message);
276
+ handler(message);
277
+ };
278
+ this.page.on("console", wrapped as any);
279
+ return () => {
280
+ this.page.off("console", wrapped as any);
281
+ };
282
+ }
283
+
284
+ const wrapped = (response: {
285
+ request(): { method(): string };
286
+ url(): string;
287
+ status(): number;
288
+ headers(): Record<string, string>;
289
+ }) => {
290
+ const entry = {
291
+ method: response.request().method(),
292
+ url: response.url(),
293
+ status: response.status(),
294
+ contentType: response.headers()["content-type"] || "",
295
+ timestampMs: Date.now(),
296
+ };
297
+ this.networkLogBuffer.push(entry);
298
+ handler(entry);
299
+ };
300
+ this.page.on("response", wrapped as any);
301
+ return () => {
302
+ this.page.off("response", wrapped as any);
303
+ };
304
+ }
305
+
306
+ getRuntimeArtifacts() {
307
+ return {
308
+ consoleLogs: [...this.consoleLogBuffer],
309
+ networkLogs: [...this.networkLogBuffer],
310
+ };
311
+ }
312
+
313
+ resetRuntimeArtifacts(): void {
314
+ this.consoleLogBuffer = [];
315
+ this.networkLogBuffer = [];
316
+ }
317
+
318
+ async getUrl(): Promise<string> {
319
+ return this.page.url();
320
+ }
321
+
322
+ async submitTextEntry(selector: string): Promise<void> {
323
+ const resolvedSelector = await this.materializeSelector(selector);
324
+ const el = await this.page.waitForSelector(resolvedSelector, {
325
+ timeout: 5000,
326
+ });
327
+ if (el) {
328
+ await el.focus();
329
+ await this.page.keyboard.press("Enter");
330
+ }
331
+ }
332
+
333
+ // --- Popup / multi-tab support ---
334
+
335
+ private nextTabId = 1;
336
+ private pageMap = new Map<number, Page>();
337
+ private currentTabId = 0;
338
+
339
+ getCurrentTabId(): number {
340
+ if (this.currentTabId === 0) {
341
+ this.currentTabId = this.nextTabId++;
342
+ this.pageMap.set(this.currentTabId, this.page);
343
+ }
344
+ return this.currentTabId;
345
+ }
346
+
347
+ async waitForNewTab(timeoutMs = 10000): Promise<number | null> {
348
+ const browser = this.page.browser();
349
+ return new Promise<number | null>(resolve => {
350
+ let settled = false;
351
+ const handler = async (target: {
352
+ type(): string;
353
+ page(): Promise<Page | null>;
354
+ }) => {
355
+ if (settled) return;
356
+ if (target.type() !== "page") return;
357
+ const newPage = await target.page();
358
+ if (!newPage) return;
359
+ settled = true;
360
+ browser.off("targetcreated", handler as any);
361
+ clearTimeout(timer);
362
+ const id = this.nextTabId++;
363
+ this.pageMap.set(id, newPage);
364
+ resolve(id);
365
+ };
366
+ const timer = setTimeout(() => {
367
+ if (settled) return;
368
+ settled = true;
369
+ browser.off("targetcreated", handler as any);
370
+ resolve(null);
371
+ }, timeoutMs);
372
+ browser.on("targetcreated", handler as any);
373
+ });
374
+ }
375
+
376
+ async switchToTab(tabId: number): Promise<void> {
377
+ const targetPage = this.pageMap.get(tabId);
378
+ if (!targetPage) {
379
+ throw new Error(`No page found for tab ID ${tabId}`);
380
+ }
381
+ this.page = targetPage;
382
+ this.currentTabId = tabId;
383
+ this.consoleLogBuffer = [];
384
+ this.networkLogBuffer = [];
385
+ await targetPage.bringToFront();
386
+ this.currentUrl = this.page.url();
387
+ logger.info({ tabId, url: this.currentUrl }, "switchToTab");
388
+ }
389
+
390
+ /** Access the underlying Puppeteer Page (for scanner-specific operations) */
391
+ getPage(): Page {
392
+ return this.page;
393
+ }
394
+ }
@@ -0,0 +1,17 @@
1
+ import type { Credentials } from "@sudobility/testomniac_types";
2
+
3
+ export class CredentialManager {
4
+ private credentials: Credentials | null = null;
5
+
6
+ setCredentials(creds: Credentials): void {
7
+ this.credentials = creds;
8
+ }
9
+
10
+ getCredentials(): Credentials | null {
11
+ return this.credentials;
12
+ }
13
+
14
+ hasCredentials(): boolean {
15
+ return this.credentials !== null;
16
+ }
17
+ }