@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
package/plans/RUNNER.md
ADDED
|
@@ -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
|
+
}
|