@rightkit/qa 0.1.0 → 0.2.1
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/dist/cli.js +42 -4
- package/dist/evidence.d.ts +27 -1
- package/dist/evidence.js +86 -5
- package/dist/frames.d.ts +39 -0
- package/dist/frames.js +90 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/loopback.d.ts +19 -4
- package/dist/loopback.js +99 -22
- package/dist/paint.d.ts +48 -0
- package/dist/paint.js +134 -0
- package/dist/panes.d.ts +39 -0
- package/dist/panes.js +102 -0
- package/dist/workspace.d.ts +46 -0
- package/dist/workspace.js +151 -0
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -4,7 +4,8 @@ import fs from 'node:fs';
|
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import { createRequire } from 'node:module';
|
|
6
6
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
7
|
-
import { addEvidenceCheck, createEvidenceBundle, finishEvidenceBundle, writeEvidenceBundle, } from './evidence.js';
|
|
7
|
+
import { addEvidenceArtifact, addEvidenceCheck, createEvidenceBundle, finishEvidenceBundle, writeEvidenceBundle, } from './evidence.js';
|
|
8
|
+
import { createQaWorkspace, pruneAbandonedWorkspaces } from './workspace.js';
|
|
8
9
|
import { createWdioConfig, defineRightQaConfig } from './native.js';
|
|
9
10
|
import { acquireRunOwnership } from './ownership.js';
|
|
10
11
|
import { runOwnedProcess } from './process.js';
|
|
@@ -17,10 +18,13 @@ export async function runNativeQa(rawConfig, options = {}) {
|
|
|
17
18
|
throw new Error(`native QA binary not found: ${binary}`);
|
|
18
19
|
const cacheRoot = path.resolve(app.appRoot, app.evidenceDir ?? '.cache/rightkit-qa');
|
|
19
20
|
const runId = crypto.randomUUID();
|
|
20
|
-
|
|
21
|
+
// Earlier crashed runs left their directories behind; sweep them before adding
|
|
22
|
+
// one more, and never touch the run about to start.
|
|
23
|
+
pruneAbandonedWorkspaces(cacheRoot, { keep: [runId] });
|
|
24
|
+
const workspace = createQaWorkspace({ root: cacheRoot, runId, appKey: app.app });
|
|
25
|
+
const runRoot = workspace.root;
|
|
21
26
|
const generatedConfig = path.join(runRoot, 'wdio.generated.mjs');
|
|
22
27
|
const evidencePath = path.join(runRoot, 'evidence.json');
|
|
23
|
-
fs.mkdirSync(runRoot, { recursive: true });
|
|
24
28
|
fs.writeFileSync(generatedConfig, [
|
|
25
29
|
`import { Agent, setGlobalDispatcher } from ${JSON.stringify(resolveUndiciEntry())};`,
|
|
26
30
|
'setGlobalDispatcher(new Agent());',
|
|
@@ -38,15 +42,21 @@ export async function runNativeQa(rawConfig, options = {}) {
|
|
|
38
42
|
timeoutMs: options.timeoutMs ?? 5 * 60_000,
|
|
39
43
|
env: {
|
|
40
44
|
...process.env,
|
|
41
|
-
|
|
45
|
+
// The isolated data directory is what makes this a separate instance
|
|
46
|
+
// rather than a second claimant on the installed app's single-instance lock.
|
|
47
|
+
...workspace.env,
|
|
42
48
|
RIGHTKIT_QA_HIDDEN: '1',
|
|
43
49
|
TAURI_WEBDRIVER_PORT: String(options.embeddedPort ?? 4445),
|
|
44
50
|
},
|
|
45
51
|
});
|
|
46
52
|
const passed = result.code === 0;
|
|
53
|
+
// Bind the receipt to the driver log actually produced, so "the run passed"
|
|
54
|
+
// is checkable against bytes rather than taken on trust.
|
|
55
|
+
const logArtifacts = recordRunArtifacts(evidence, path.join(runRoot, 'wdio'));
|
|
47
56
|
addEvidenceCheck(evidence, {
|
|
48
57
|
name: 'wdio.run',
|
|
49
58
|
status: passed ? 'passed' : 'failed',
|
|
59
|
+
artifacts: logArtifacts,
|
|
50
60
|
details: {
|
|
51
61
|
code: result.code,
|
|
52
62
|
signal: result.signal,
|
|
@@ -113,6 +123,34 @@ function valueAfter(argv, flag) {
|
|
|
113
123
|
const index = argv.indexOf(flag);
|
|
114
124
|
return index >= 0 ? argv[index + 1] : undefined;
|
|
115
125
|
}
|
|
126
|
+
/**
|
|
127
|
+
* Hash whatever the driver actually wrote. Missing logs are simply not recorded
|
|
128
|
+
* — a check that references nothing is honest; a check that references a file
|
|
129
|
+
* that was never hashed is refused by addEvidenceCheck.
|
|
130
|
+
*/
|
|
131
|
+
function recordRunArtifacts(evidence, logDir) {
|
|
132
|
+
const names = [];
|
|
133
|
+
let entries;
|
|
134
|
+
try {
|
|
135
|
+
entries = fs.readdirSync(logDir);
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
return names;
|
|
139
|
+
}
|
|
140
|
+
for (const entry of entries.sort()) {
|
|
141
|
+
const file = path.join(logDir, entry);
|
|
142
|
+
try {
|
|
143
|
+
if (!fs.statSync(file).isFile())
|
|
144
|
+
continue;
|
|
145
|
+
addEvidenceArtifact(evidence, { name: `wdio/${entry}`, path: file });
|
|
146
|
+
names.push(`wdio/${entry}`);
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
// An unreadable log is not evidence; leaving it unreferenced is the point.
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return names;
|
|
153
|
+
}
|
|
116
154
|
function tail(value, maxLength = 4_000) {
|
|
117
155
|
return value.length <= maxLength ? value : value.slice(-maxLength);
|
|
118
156
|
}
|
package/dist/evidence.d.ts
CHANGED
|
@@ -1,12 +1,21 @@
|
|
|
1
1
|
export type EvidenceCheckStatus = 'passed' | 'failed' | 'skipped';
|
|
2
2
|
export type EvidenceRunStatus = 'running' | 'passed' | 'failed';
|
|
3
|
+
export interface EvidenceArtifact {
|
|
4
|
+
name: string;
|
|
5
|
+
path: string;
|
|
6
|
+
sha256: string;
|
|
7
|
+
sizeBytes: number;
|
|
8
|
+
recordedAt: string;
|
|
9
|
+
}
|
|
3
10
|
export interface EvidenceCheck {
|
|
4
11
|
name: string;
|
|
5
12
|
status: EvidenceCheckStatus;
|
|
6
13
|
details?: Record<string, unknown>;
|
|
14
|
+
/** Names of artifacts recorded via addEvidenceArtifact that back this check. */
|
|
15
|
+
artifacts?: string[];
|
|
7
16
|
}
|
|
8
17
|
export interface EvidenceBundle {
|
|
9
|
-
schemaVersion:
|
|
18
|
+
schemaVersion: 2;
|
|
10
19
|
app: string;
|
|
11
20
|
platform: string;
|
|
12
21
|
runId: string;
|
|
@@ -14,6 +23,7 @@ export interface EvidenceBundle {
|
|
|
14
23
|
finishedAt?: string;
|
|
15
24
|
status: EvidenceRunStatus;
|
|
16
25
|
checks: EvidenceCheck[];
|
|
26
|
+
artifacts: EvidenceArtifact[];
|
|
17
27
|
}
|
|
18
28
|
export interface EvidenceBundleInput {
|
|
19
29
|
app: string;
|
|
@@ -22,9 +32,25 @@ export interface EvidenceBundleInput {
|
|
|
22
32
|
startedAt?: string;
|
|
23
33
|
}
|
|
24
34
|
export declare function createEvidenceBundle(input: EvidenceBundleInput): EvidenceBundle;
|
|
35
|
+
/**
|
|
36
|
+
* Hash a file into the bundle. Reading happens now, not at write time, so a file
|
|
37
|
+
* that is later replaced or deleted cannot masquerade as the one that was seen.
|
|
38
|
+
*/
|
|
39
|
+
export declare function addEvidenceArtifact(bundle: EvidenceBundle, artifact: {
|
|
40
|
+
name: string;
|
|
41
|
+
path: string;
|
|
42
|
+
}, now?: Date): EvidenceArtifact;
|
|
25
43
|
export declare function addEvidenceCheck(bundle: EvidenceBundle, check: EvidenceCheck): void;
|
|
26
44
|
export declare function finishEvidenceBundle(bundle: EvidenceBundle, result: {
|
|
27
45
|
status: Exclude<EvidenceRunStatus, 'running'>;
|
|
28
46
|
finishedAt?: string;
|
|
29
47
|
}): void;
|
|
48
|
+
/**
|
|
49
|
+
* Re-hash every recorded artifact. Returns the artifacts whose bytes no longer
|
|
50
|
+
* match — empty means the receipt still describes what is on disk.
|
|
51
|
+
*/
|
|
52
|
+
export declare function verifyEvidenceArtifacts(bundle: EvidenceBundle): Array<{
|
|
53
|
+
name: string;
|
|
54
|
+
reason: string;
|
|
55
|
+
}>;
|
|
30
56
|
export declare function writeEvidenceBundle(filePath: string, bundle: EvidenceBundle): void;
|
package/dist/evidence.js
CHANGED
|
@@ -1,30 +1,107 @@
|
|
|
1
|
+
// A receipt that names a file without hashing it is a claim, not evidence.
|
|
2
|
+
//
|
|
3
|
+
// The previous bundle recorded check names, statuses, and free-form details, then
|
|
4
|
+
// declared the run passed. Nothing tied those claims to the artifacts they were
|
|
5
|
+
// about: a screenshot could be regenerated, truncated, or never written at all
|
|
6
|
+
// and the receipt read exactly the same. "Signature verified" and "verified the
|
|
7
|
+
// signature of THESE bytes" are different statements, and only the second one
|
|
8
|
+
// survives someone asking later.
|
|
9
|
+
//
|
|
10
|
+
// So every artifact a check refers to is hashed at the moment it is recorded, a
|
|
11
|
+
// check may only reference artifacts that were hashed, and the bundle can be
|
|
12
|
+
// re-verified against the filesystem afterwards.
|
|
13
|
+
import crypto from 'node:crypto';
|
|
1
14
|
import fs from 'node:fs';
|
|
2
15
|
import path from 'node:path';
|
|
3
16
|
export function createEvidenceBundle(input) {
|
|
4
17
|
return {
|
|
5
|
-
schemaVersion:
|
|
18
|
+
schemaVersion: 2,
|
|
6
19
|
app: requireText(input.app, 'app'),
|
|
7
20
|
platform: requireText(input.platform, 'platform'),
|
|
8
21
|
runId: requireText(input.runId, 'runId'),
|
|
9
22
|
startedAt: input.startedAt ?? new Date().toISOString(),
|
|
10
23
|
status: 'running',
|
|
11
24
|
checks: [],
|
|
25
|
+
artifacts: [],
|
|
12
26
|
};
|
|
13
27
|
}
|
|
28
|
+
/**
|
|
29
|
+
* Hash a file into the bundle. Reading happens now, not at write time, so a file
|
|
30
|
+
* that is later replaced or deleted cannot masquerade as the one that was seen.
|
|
31
|
+
*/
|
|
32
|
+
export function addEvidenceArtifact(bundle, artifact, now = new Date()) {
|
|
33
|
+
requireRunning(bundle);
|
|
34
|
+
const name = requireText(artifact.name, 'artifact name');
|
|
35
|
+
if (bundle.artifacts.some((existing) => existing.name === name)) {
|
|
36
|
+
throw new Error(`QA evidence already records an artifact named ${name}`);
|
|
37
|
+
}
|
|
38
|
+
const resolved = path.resolve(requireText(artifact.path, 'artifact path'));
|
|
39
|
+
let bytes;
|
|
40
|
+
try {
|
|
41
|
+
bytes = fs.readFileSync(resolved);
|
|
42
|
+
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
throw new Error(`cannot record QA evidence artifact ${name}: ${resolved} is unreadable (${error.message})`);
|
|
45
|
+
}
|
|
46
|
+
const recorded = {
|
|
47
|
+
name,
|
|
48
|
+
path: resolved,
|
|
49
|
+
sha256: crypto.createHash('sha256').update(bytes).digest('hex'),
|
|
50
|
+
sizeBytes: bytes.length,
|
|
51
|
+
recordedAt: now.toISOString(),
|
|
52
|
+
};
|
|
53
|
+
bundle.artifacts.push(recorded);
|
|
54
|
+
return recorded;
|
|
55
|
+
}
|
|
14
56
|
export function addEvidenceCheck(bundle, check) {
|
|
15
|
-
|
|
16
|
-
|
|
57
|
+
requireRunning(bundle);
|
|
58
|
+
const name = requireText(check.name, 'check name');
|
|
59
|
+
const artifacts = check.artifacts ?? [];
|
|
60
|
+
const unknown = artifacts.filter((reference) => !bundle.artifacts.some((item) => item.name === reference));
|
|
61
|
+
if (unknown.length > 0) {
|
|
62
|
+
throw new Error(`QA check ${name} references artifacts that were never hashed: ${unknown.join(', ')}. `
|
|
63
|
+
+ 'Record them with addEvidenceArtifact so the receipt is bound to real bytes.');
|
|
17
64
|
}
|
|
18
|
-
bundle.checks.push({ ...check, name
|
|
65
|
+
bundle.checks.push({ ...check, name, ...(artifacts.length > 0 ? { artifacts: [...artifacts] } : {}) });
|
|
19
66
|
}
|
|
20
67
|
export function finishEvidenceBundle(bundle, result) {
|
|
21
68
|
bundle.status = result.status;
|
|
22
69
|
bundle.finishedAt = result.finishedAt ?? new Date().toISOString();
|
|
23
70
|
}
|
|
71
|
+
/**
|
|
72
|
+
* Re-hash every recorded artifact. Returns the artifacts whose bytes no longer
|
|
73
|
+
* match — empty means the receipt still describes what is on disk.
|
|
74
|
+
*/
|
|
75
|
+
export function verifyEvidenceArtifacts(bundle) {
|
|
76
|
+
const drift = [];
|
|
77
|
+
for (const artifact of bundle.artifacts) {
|
|
78
|
+
let bytes;
|
|
79
|
+
try {
|
|
80
|
+
bytes = fs.readFileSync(artifact.path);
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
drift.push({ name: artifact.name, reason: `missing at ${artifact.path}` });
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
const sha256 = crypto.createHash('sha256').update(bytes).digest('hex');
|
|
87
|
+
if (sha256 !== artifact.sha256) {
|
|
88
|
+
drift.push({ name: artifact.name, reason: `sha256 ${sha256} does not match recorded ${artifact.sha256}` });
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return drift;
|
|
92
|
+
}
|
|
24
93
|
export function writeEvidenceBundle(filePath, bundle) {
|
|
25
94
|
if (bundle.status === 'running' || !bundle.finishedAt) {
|
|
26
95
|
throw new Error('refusing to write unfinished QA evidence');
|
|
27
96
|
}
|
|
97
|
+
// A passing receipt is the one people trust without reading, so it is the one
|
|
98
|
+
// whose artifacts must still be intact at write time.
|
|
99
|
+
if (bundle.status === 'passed') {
|
|
100
|
+
const drift = verifyEvidenceArtifacts(bundle);
|
|
101
|
+
if (drift.length > 0) {
|
|
102
|
+
throw new Error(`refusing to write a passing QA receipt whose evidence changed: ${drift.map((item) => `${item.name} (${item.reason})`).join('; ')}`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
28
105
|
const resolved = path.resolve(filePath);
|
|
29
106
|
fs.mkdirSync(path.dirname(resolved), { recursive: true });
|
|
30
107
|
const temporary = `${resolved}.tmp-${process.pid}`;
|
|
@@ -36,8 +113,12 @@ export function writeEvidenceBundle(filePath, bundle) {
|
|
|
36
113
|
fs.rmSync(temporary, { force: true });
|
|
37
114
|
}
|
|
38
115
|
}
|
|
116
|
+
function requireRunning(bundle) {
|
|
117
|
+
if (bundle.status !== 'running')
|
|
118
|
+
throw new Error('cannot modify a QA evidence bundle after the run has finished');
|
|
119
|
+
}
|
|
39
120
|
function requireText(value, name) {
|
|
40
|
-
const normalized = value.trim();
|
|
121
|
+
const normalized = String(value ?? '').trim();
|
|
41
122
|
if (!normalized)
|
|
42
123
|
throw new Error(`${name} is required`);
|
|
43
124
|
return normalized;
|
package/dist/frames.d.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export interface QaFrameBrowser {
|
|
2
|
+
execute<T>(script: string, ...args: unknown[]): Promise<T>;
|
|
3
|
+
switchToFrame(target: number | null | unknown): Promise<unknown>;
|
|
4
|
+
switchToParentFrame(): Promise<unknown>;
|
|
5
|
+
}
|
|
6
|
+
/** Index path from the top document to a nested frame (`[]` is the top document). */
|
|
7
|
+
export type FramePath = readonly number[];
|
|
8
|
+
export interface FrameMatch {
|
|
9
|
+
framePath: FramePath;
|
|
10
|
+
count: number;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Run `body` with the driver focused on `framePath`, then return to the top
|
|
14
|
+
* document. Restoration happens in a finally block: a spec that throws mid-frame
|
|
15
|
+
* used to leave every later selector silently scoped to that iframe.
|
|
16
|
+
*/
|
|
17
|
+
export declare function withFrame<T>(browser: QaFrameBrowser, framePath: FramePath, body: () => Promise<T>): Promise<T>;
|
|
18
|
+
export declare function countMatchesScript(selector: string): string;
|
|
19
|
+
export declare function countFramesScript(): string;
|
|
20
|
+
/**
|
|
21
|
+
* Search every document — top level and nested frames — and report each one that
|
|
22
|
+
* matches. Callers get the ambiguity rather than a single arbitrary winner.
|
|
23
|
+
*/
|
|
24
|
+
export declare function findAcrossFrames(browser: QaFrameBrowser, selector: string, { maxDepth }?: {
|
|
25
|
+
maxDepth?: number;
|
|
26
|
+
}): Promise<FrameMatch[]>;
|
|
27
|
+
/**
|
|
28
|
+
* Resolve the one document a selector is allowed to match. Ambiguity fails with
|
|
29
|
+
* both locations named, because "it matched somewhere" is what let chrome-level
|
|
30
|
+
* matches stand in for content-level ones.
|
|
31
|
+
*/
|
|
32
|
+
export declare function resolveUniqueFrame(browser: QaFrameBrowser, selector: string, options?: {
|
|
33
|
+
maxDepth?: number;
|
|
34
|
+
}): Promise<FramePath>;
|
|
35
|
+
/** Run `body` inside the single document that matches `selector`. */
|
|
36
|
+
export declare function withContentFrame<T>(browser: QaFrameBrowser, selector: string, body: (framePath: FramePath) => Promise<T>, options?: {
|
|
37
|
+
maxDepth?: number;
|
|
38
|
+
}): Promise<T>;
|
|
39
|
+
export declare function describeFrame(framePath: FramePath): string;
|
package/dist/frames.js
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// Selectors must name a document, not just an element.
|
|
2
|
+
//
|
|
3
|
+
// The app renders untrusted document content inside iframes. A selector run
|
|
4
|
+
// against the top-level document therefore searches the CHROME, not the content
|
|
5
|
+
// — and because the chrome often contains a same-named element (a toolbar title,
|
|
6
|
+
// a placeholder, a template node), the query succeeds and the assertion passes
|
|
7
|
+
// while never having looked at the thing under test.
|
|
8
|
+
//
|
|
9
|
+
// Two rules encoded here:
|
|
10
|
+
// - entering a frame is always paired with leaving it, even when the body throws;
|
|
11
|
+
// - a selector that matches in more than one document is an error, not a
|
|
12
|
+
// coin-flip resolved by document order.
|
|
13
|
+
/**
|
|
14
|
+
* Run `body` with the driver focused on `framePath`, then return to the top
|
|
15
|
+
* document. Restoration happens in a finally block: a spec that throws mid-frame
|
|
16
|
+
* used to leave every later selector silently scoped to that iframe.
|
|
17
|
+
*/
|
|
18
|
+
export async function withFrame(browser, framePath, body) {
|
|
19
|
+
await enterFrame(browser, framePath);
|
|
20
|
+
try {
|
|
21
|
+
return await body();
|
|
22
|
+
}
|
|
23
|
+
finally {
|
|
24
|
+
await browser.switchToFrame(null);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
async function enterFrame(browser, framePath) {
|
|
28
|
+
await browser.switchToFrame(null);
|
|
29
|
+
for (const index of framePath) {
|
|
30
|
+
if (!Number.isInteger(index) || index < 0)
|
|
31
|
+
throw new Error(`invalid frame index in path: ${JSON.stringify(framePath)}`);
|
|
32
|
+
await browser.switchToFrame(index);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
export function countMatchesScript(selector) {
|
|
36
|
+
return `return document.querySelectorAll(${JSON.stringify(selector)}).length;`;
|
|
37
|
+
}
|
|
38
|
+
export function countFramesScript() {
|
|
39
|
+
return 'return window.frames.length;';
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Search every document — top level and nested frames — and report each one that
|
|
43
|
+
* matches. Callers get the ambiguity rather than a single arbitrary winner.
|
|
44
|
+
*/
|
|
45
|
+
export async function findAcrossFrames(browser, selector, { maxDepth = 4 } = {}) {
|
|
46
|
+
const matches = [];
|
|
47
|
+
async function visit(framePath, depth) {
|
|
48
|
+
await enterFrame(browser, framePath);
|
|
49
|
+
const count = Number(await browser.execute(countMatchesScript(selector)));
|
|
50
|
+
if (count > 0)
|
|
51
|
+
matches.push({ framePath: [...framePath], count });
|
|
52
|
+
if (depth >= maxDepth)
|
|
53
|
+
return;
|
|
54
|
+
const frameCount = Number(await browser.execute(countFramesScript()));
|
|
55
|
+
for (let index = 0; index < frameCount; index += 1) {
|
|
56
|
+
await visit([...framePath, index], depth + 1);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
try {
|
|
60
|
+
await visit([], 0);
|
|
61
|
+
}
|
|
62
|
+
finally {
|
|
63
|
+
await browser.switchToFrame(null);
|
|
64
|
+
}
|
|
65
|
+
return matches;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Resolve the one document a selector is allowed to match. Ambiguity fails with
|
|
69
|
+
* both locations named, because "it matched somewhere" is what let chrome-level
|
|
70
|
+
* matches stand in for content-level ones.
|
|
71
|
+
*/
|
|
72
|
+
export async function resolveUniqueFrame(browser, selector, options = {}) {
|
|
73
|
+
const matches = await findAcrossFrames(browser, selector, options);
|
|
74
|
+
if (matches.length === 0)
|
|
75
|
+
throw new Error(`selector matched no document: ${selector}`);
|
|
76
|
+
if (matches.length > 1) {
|
|
77
|
+
const where = matches.map((match) => `${describeFrame(match.framePath)} (${match.count})`).join(', ');
|
|
78
|
+
throw new Error(`selector ${selector} matches in ${matches.length} documents: ${where}. `
|
|
79
|
+
+ 'Scope it with withFrame() so the assertion names the document it means.');
|
|
80
|
+
}
|
|
81
|
+
return matches[0].framePath;
|
|
82
|
+
}
|
|
83
|
+
/** Run `body` inside the single document that matches `selector`. */
|
|
84
|
+
export async function withContentFrame(browser, selector, body, options = {}) {
|
|
85
|
+
const framePath = await resolveUniqueFrame(browser, selector, options);
|
|
86
|
+
return withFrame(browser, framePath, () => body(framePath));
|
|
87
|
+
}
|
|
88
|
+
export function describeFrame(framePath) {
|
|
89
|
+
return framePath.length === 0 ? 'top document' : `frame ${framePath.join('.')}`;
|
|
90
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
export * from './evidence.js';
|
|
2
|
+
export * from './frames.js';
|
|
2
3
|
export * from './guard.js';
|
|
3
4
|
export * from './loopback.js';
|
|
4
5
|
export * from './native.js';
|
|
5
6
|
export * from './ownership.js';
|
|
7
|
+
export * from './paint.js';
|
|
8
|
+
export * from './panes.js';
|
|
6
9
|
export * from './process.js';
|
|
10
|
+
export * from './workspace.js';
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
export * from './evidence.js';
|
|
2
|
+
export * from './frames.js';
|
|
2
3
|
export * from './guard.js';
|
|
3
4
|
export * from './loopback.js';
|
|
4
5
|
export * from './native.js';
|
|
5
6
|
export * from './ownership.js';
|
|
7
|
+
export * from './paint.js';
|
|
8
|
+
export * from './panes.js';
|
|
6
9
|
export * from './process.js';
|
|
10
|
+
export * from './workspace.js';
|
package/dist/loopback.d.ts
CHANGED
|
@@ -2,18 +2,33 @@ export interface LoopbackClientOptions {
|
|
|
2
2
|
baseUrl: string;
|
|
3
3
|
token: string;
|
|
4
4
|
pollIntervalMs?: number;
|
|
5
|
+
/** Ceiling for a single HTTP round trip. */
|
|
6
|
+
requestTimeoutMs?: number;
|
|
7
|
+
/** Default ceiling for a queued action, measured from queue to result. */
|
|
8
|
+
actionTimeoutMs?: number;
|
|
5
9
|
fetchImpl?: typeof fetch;
|
|
10
|
+
newRequestId?: () => string;
|
|
6
11
|
}
|
|
7
12
|
export interface ActionResult {
|
|
8
13
|
id: string;
|
|
9
14
|
ok: boolean;
|
|
10
15
|
message?: string;
|
|
16
|
+
requestId?: string;
|
|
11
17
|
[key: string]: unknown;
|
|
12
18
|
}
|
|
19
|
+
export interface RequestOptions {
|
|
20
|
+
timeoutMs?: number;
|
|
21
|
+
requestId?: string;
|
|
22
|
+
}
|
|
13
23
|
export interface LoopbackClient {
|
|
14
|
-
request<T = unknown>(method: string, route: string, body?: unknown): Promise<T>;
|
|
15
|
-
action(kind: string, payload?: Record<string, unknown>, options?:
|
|
16
|
-
|
|
17
|
-
|
|
24
|
+
request<T = unknown>(method: string, route: string, body?: unknown, options?: RequestOptions): Promise<T>;
|
|
25
|
+
action(kind: string, payload?: Record<string, unknown>, options?: RequestOptions): Promise<ActionResult>;
|
|
26
|
+
}
|
|
27
|
+
export declare class LoopbackTimeoutError extends Error {
|
|
28
|
+
readonly requestId: string | undefined;
|
|
29
|
+
readonly timeoutMs: number;
|
|
30
|
+
constructor(what: string, timeoutMs: number, requestId?: string);
|
|
18
31
|
}
|
|
32
|
+
export declare const DEFAULT_REQUEST_TIMEOUT_MS = 10000;
|
|
33
|
+
export declare const DEFAULT_ACTION_TIMEOUT_MS = 30000;
|
|
19
34
|
export declare function createLoopbackClient(options: LoopbackClientOptions): LoopbackClient;
|
package/dist/loopback.js
CHANGED
|
@@ -1,19 +1,64 @@
|
|
|
1
|
+
// Control-plane client for the app's loopback QA endpoint.
|
|
2
|
+
//
|
|
3
|
+
// Two properties this file exists to guarantee:
|
|
4
|
+
//
|
|
5
|
+
// 1. NO CALL IS UNBOUNDED. Every fetch carries its own abort deadline, and the
|
|
6
|
+
// action poll's deadline covers the whole queue-and-await cycle. An app that
|
|
7
|
+
// accepts a request and then blocks — a resize that never repaints, a window
|
|
8
|
+
// operation waiting on a message loop that is itself waiting on us — must
|
|
9
|
+
// surface as a timed-out test, not a hung run that a human eventually kills.
|
|
10
|
+
// 2. RESULTS ARE CORRELATED, NOT GUESSED. Every action carries a caller-minted
|
|
11
|
+
// request id which is echoed back in the result. Matching on "the most recent
|
|
12
|
+
// result of this kind" attributes one action's outcome to another whenever
|
|
13
|
+
// two actions of the same kind overlap.
|
|
14
|
+
import { randomUUID } from 'node:crypto';
|
|
15
|
+
export class LoopbackTimeoutError extends Error {
|
|
16
|
+
requestId;
|
|
17
|
+
timeoutMs;
|
|
18
|
+
constructor(what, timeoutMs, requestId) {
|
|
19
|
+
super(`${what} exceeded ${timeoutMs}ms${requestId ? ` (requestId ${requestId})` : ''}`);
|
|
20
|
+
this.name = 'LoopbackTimeoutError';
|
|
21
|
+
this.timeoutMs = timeoutMs;
|
|
22
|
+
this.requestId = requestId;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
export const DEFAULT_REQUEST_TIMEOUT_MS = 10_000;
|
|
26
|
+
export const DEFAULT_ACTION_TIMEOUT_MS = 30_000;
|
|
1
27
|
export function createLoopbackClient(options) {
|
|
2
28
|
const baseUrl = validateLoopbackUrl(options.baseUrl);
|
|
3
29
|
const token = options.token.trim();
|
|
4
30
|
if (!token)
|
|
5
31
|
throw new Error('loopback bearer token is required');
|
|
6
32
|
const fetchImpl = options.fetchImpl ?? fetch;
|
|
7
|
-
const pollIntervalMs = options.pollIntervalMs ?? 250;
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
33
|
+
const pollIntervalMs = requirePositive(options.pollIntervalMs ?? 250, 'pollIntervalMs');
|
|
34
|
+
const requestTimeoutMs = requirePositive(options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS, 'requestTimeoutMs');
|
|
35
|
+
const actionTimeoutMs = requirePositive(options.actionTimeoutMs ?? DEFAULT_ACTION_TIMEOUT_MS, 'actionTimeoutMs');
|
|
36
|
+
const newRequestId = options.newRequestId ?? (() => randomUUID());
|
|
37
|
+
async function request(method, route, body, requestOptions = {}) {
|
|
38
|
+
const timeoutMs = requirePositive(requestOptions.timeoutMs ?? requestTimeoutMs, 'timeoutMs');
|
|
39
|
+
const controller = new AbortController();
|
|
40
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
41
|
+
let response;
|
|
42
|
+
try {
|
|
43
|
+
response = await fetchImpl(new URL(route, baseUrl), {
|
|
44
|
+
method,
|
|
45
|
+
headers: {
|
|
46
|
+
authorization: `Bearer ${token}`,
|
|
47
|
+
'content-type': 'application/json',
|
|
48
|
+
...(requestOptions.requestId ? { 'x-rightkit-qa-request-id': requestOptions.requestId } : {}),
|
|
49
|
+
},
|
|
50
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
51
|
+
signal: controller.signal,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
catch (error) {
|
|
55
|
+
if (controller.signal.aborted)
|
|
56
|
+
throw new LoopbackTimeoutError(`${method} ${route}`, timeoutMs, requestOptions.requestId);
|
|
57
|
+
throw error;
|
|
58
|
+
}
|
|
59
|
+
finally {
|
|
60
|
+
clearTimeout(timer);
|
|
61
|
+
}
|
|
17
62
|
const text = await response.text();
|
|
18
63
|
let data = null;
|
|
19
64
|
try {
|
|
@@ -22,39 +67,71 @@ export function createLoopbackClient(options) {
|
|
|
22
67
|
catch {
|
|
23
68
|
data = text;
|
|
24
69
|
}
|
|
25
|
-
if (!response.ok)
|
|
70
|
+
if (!response.ok)
|
|
26
71
|
throw new Error(`${method} ${route} failed ${response.status}: ${text}`);
|
|
27
|
-
}
|
|
28
72
|
return data;
|
|
29
73
|
}
|
|
30
74
|
async function action(kind, payload = {}, actionOptions = {}) {
|
|
31
|
-
const
|
|
75
|
+
const requestId = actionOptions.requestId ?? newRequestId();
|
|
76
|
+
const timeoutMs = requirePositive(actionOptions.timeoutMs ?? actionTimeoutMs, 'timeoutMs');
|
|
77
|
+
const deadline = Date.now() + timeoutMs;
|
|
78
|
+
const queued = await request('POST', '/app-control/actions', { kind, payload, requestId }, { requestId, timeoutMs: remaining(deadline, timeoutMs, requestId, `app-control ${kind}`) });
|
|
32
79
|
const id = queued.action?.id;
|
|
33
80
|
if (!id)
|
|
34
|
-
throw new Error(`app-control ${kind} did not return an action id`);
|
|
35
|
-
const deadline = Date.now() + (actionOptions.timeoutMs ?? 30_000);
|
|
81
|
+
throw new Error(`app-control ${kind} did not return an action id (requestId ${requestId})`);
|
|
36
82
|
while (Date.now() < deadline) {
|
|
37
|
-
await delay(pollIntervalMs);
|
|
38
|
-
|
|
39
|
-
|
|
83
|
+
await delay(Math.min(pollIntervalMs, Math.max(1, deadline - Date.now())));
|
|
84
|
+
let state;
|
|
85
|
+
try {
|
|
86
|
+
state = await request('GET', '/app-control/state', undefined, { requestId, timeoutMs: remaining(deadline, timeoutMs, requestId, `app-control ${kind}`) });
|
|
87
|
+
}
|
|
88
|
+
catch (error) {
|
|
89
|
+
// A poll cut short by the action's own deadline is an action timeout.
|
|
90
|
+
// Reporting the truncated per-request budget instead would name a number
|
|
91
|
+
// the caller never chose and hide the deadline that actually expired.
|
|
92
|
+
if (error instanceof LoopbackTimeoutError && Date.now() >= deadline) {
|
|
93
|
+
throw new LoopbackTimeoutError(`app-control ${kind}`, timeoutMs, requestId);
|
|
94
|
+
}
|
|
95
|
+
throw error;
|
|
96
|
+
}
|
|
97
|
+
// Prefer the request id; fall back to the action id for apps that predate
|
|
98
|
+
// request-id echo. Never fall back to "the newest result".
|
|
99
|
+
const result = state.recentResults?.find((entry) => entry.requestId === requestId)
|
|
100
|
+
?? state.recentResults?.find((entry) => entry.id === id && entry.requestId === undefined);
|
|
40
101
|
if (!result)
|
|
41
102
|
continue;
|
|
42
103
|
if (!result.ok)
|
|
43
|
-
throw new Error(`app-control ${kind} failed: ${result.message ?? 'unknown error'}`);
|
|
44
|
-
return result;
|
|
104
|
+
throw new Error(`app-control ${kind} failed: ${result.message ?? 'unknown error'} (requestId ${requestId})`);
|
|
105
|
+
return { ...result, requestId };
|
|
45
106
|
}
|
|
46
|
-
throw new
|
|
107
|
+
throw new LoopbackTimeoutError(`app-control ${kind}`, timeoutMs, requestId);
|
|
47
108
|
}
|
|
48
109
|
return { request, action };
|
|
49
110
|
}
|
|
111
|
+
/**
|
|
112
|
+
* Time left before the action's deadline. Exhaustion reports the ORIGINAL budget,
|
|
113
|
+
* not the zero milliseconds remaining — "exceeded 0ms" tells the reader nothing
|
|
114
|
+
* about what was actually allowed.
|
|
115
|
+
*/
|
|
116
|
+
function remaining(deadline, budgetMs, requestId, what) {
|
|
117
|
+
const left = deadline - Date.now();
|
|
118
|
+
if (left <= 0)
|
|
119
|
+
throw new LoopbackTimeoutError(what, budgetMs, requestId);
|
|
120
|
+
return left;
|
|
121
|
+
}
|
|
50
122
|
function validateLoopbackUrl(value) {
|
|
51
123
|
const url = new URL(value);
|
|
52
124
|
const host = url.hostname.toLowerCase();
|
|
53
|
-
if (url.protocol !== 'http:' || !['127.0.0.1', 'localhost', '[::1]'].includes(host)) {
|
|
125
|
+
if (url.protocol !== 'http:' || !['127.0.0.1', 'localhost', '[::1]', '::1'].includes(host)) {
|
|
54
126
|
throw new Error(`QA control endpoint must be an HTTP loopback URL, received ${url.origin}`);
|
|
55
127
|
}
|
|
56
128
|
return url;
|
|
57
129
|
}
|
|
130
|
+
function requirePositive(value, name) {
|
|
131
|
+
if (!Number.isFinite(value) || value <= 0)
|
|
132
|
+
throw new Error(`${name} must be a positive number of milliseconds`);
|
|
133
|
+
return value;
|
|
134
|
+
}
|
|
58
135
|
async function delay(ms) {
|
|
59
136
|
await new Promise((resolve) => setTimeout(resolve, ms));
|
|
60
137
|
}
|
package/dist/paint.d.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
export interface QaBrowser {
|
|
2
|
+
execute<T>(script: string, ...args: unknown[]): Promise<T>;
|
|
3
|
+
executeAsync<T>(script: string, ...args: unknown[]): Promise<T>;
|
|
4
|
+
}
|
|
5
|
+
export interface PaintMetrics {
|
|
6
|
+
requestId: string;
|
|
7
|
+
/** Milliseconds from the caller's mark to the second animation frame. */
|
|
8
|
+
durationMs: number;
|
|
9
|
+
/** Milliseconds spent waiting for DOM mutations to stop. */
|
|
10
|
+
settleMs: number;
|
|
11
|
+
mutations: number;
|
|
12
|
+
/** True when rAF x2 completed; false when the page fell back to a timer. */
|
|
13
|
+
animationFrames: boolean;
|
|
14
|
+
}
|
|
15
|
+
export declare const DEFAULT_SETTLE_MS = 120;
|
|
16
|
+
export declare const DEFAULT_PAINT_TIMEOUT_MS = 10000;
|
|
17
|
+
/**
|
|
18
|
+
* Install the page-side ledger. Idempotent, so specs may call it after every
|
|
19
|
+
* navigation without accumulating observers.
|
|
20
|
+
*/
|
|
21
|
+
export declare function installPaintLedgerScript(): string;
|
|
22
|
+
/** Record the start of a measured interaction, keyed by request id. */
|
|
23
|
+
export declare function beginPaintScript(requestId: string, selector?: string): string;
|
|
24
|
+
/**
|
|
25
|
+
* Resolve once the target subtree has been quiet for `settleMs` and two
|
|
26
|
+
* animation frames have elapsed. Falls back to a timer only if the document is
|
|
27
|
+
* hidden, where the browser throttles rAF indefinitely — reported honestly via
|
|
28
|
+
* `animationFrames: false` rather than silently producing a number.
|
|
29
|
+
*/
|
|
30
|
+
export declare function awaitPaintScript(requestId: string, settleMs: number, timeoutMs: number): string;
|
|
31
|
+
export interface MeasurePaintOptions {
|
|
32
|
+
requestId: string;
|
|
33
|
+
/** CSS selector for the subtree that should render. Defaults to the document. */
|
|
34
|
+
selector?: string;
|
|
35
|
+
settleMs?: number;
|
|
36
|
+
timeoutMs?: number;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Run `trigger` and return when the resulting render has actually been painted.
|
|
40
|
+
* The mark is installed BEFORE the trigger so no mutation can slip between them.
|
|
41
|
+
*/
|
|
42
|
+
export declare function measurePaint(browser: QaBrowser, trigger: () => Promise<unknown>, options: MeasurePaintOptions): Promise<PaintMetrics>;
|
|
43
|
+
/**
|
|
44
|
+
* Assert a paint completed within budget. Separate from measurement so a spec can
|
|
45
|
+
* record a timing without failing on it, and so the failure text carries the
|
|
46
|
+
* evidence rather than just a boolean.
|
|
47
|
+
*/
|
|
48
|
+
export declare function assertPaintWithin(metrics: PaintMetrics, budgetMs: number, label: string): PaintMetrics;
|
package/dist/paint.js
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// Measure when the UI was PAINTED, not when the command returned.
|
|
2
|
+
//
|
|
3
|
+
// A driver command resolves as soon as the app acknowledges it. For anything that
|
|
4
|
+
// renders — opening a document, switching tabs, re-laying out after a resize —
|
|
5
|
+
// that acknowledgement happens well before pixels change, so timings taken from
|
|
6
|
+
// it measure the RPC and report it as UI latency. Every such number is wrong in
|
|
7
|
+
// the same direction, which is why they looked plausible.
|
|
8
|
+
//
|
|
9
|
+
// What this module measures instead: from a caller-marked start, wait for DOM
|
|
10
|
+
// mutations in the target subtree to go quiet, then wait two animation frames.
|
|
11
|
+
// The second frame callback runs after the frame containing the layout+paint work
|
|
12
|
+
// has been committed for rendering.
|
|
13
|
+
//
|
|
14
|
+
// Honest limit: rAF x2 fires after commit but before the compositor has
|
|
15
|
+
// necessarily put pixels on the physical display. It is the right signal for
|
|
16
|
+
// catching latency regressions and for ordering ("did it render before we
|
|
17
|
+
// asserted?"). It is NOT proof of pixels; when that is what you need, follow it
|
|
18
|
+
// with a screenshot comparison.
|
|
19
|
+
export const DEFAULT_SETTLE_MS = 120;
|
|
20
|
+
export const DEFAULT_PAINT_TIMEOUT_MS = 10_000;
|
|
21
|
+
const LEDGER = '__rightkitQaPaint';
|
|
22
|
+
/**
|
|
23
|
+
* Install the page-side ledger. Idempotent, so specs may call it after every
|
|
24
|
+
* navigation without accumulating observers.
|
|
25
|
+
*/
|
|
26
|
+
export function installPaintLedgerScript() {
|
|
27
|
+
return `
|
|
28
|
+
(function () {
|
|
29
|
+
if (window.${LEDGER}) return true;
|
|
30
|
+
window.${LEDGER} = { marks: new Map() };
|
|
31
|
+
return true;
|
|
32
|
+
})();
|
|
33
|
+
`;
|
|
34
|
+
}
|
|
35
|
+
/** Record the start of a measured interaction, keyed by request id. */
|
|
36
|
+
export function beginPaintScript(requestId, selector) {
|
|
37
|
+
return `
|
|
38
|
+
(function () {
|
|
39
|
+
var ledger = window.${LEDGER};
|
|
40
|
+
if (!ledger) throw new Error('paint ledger is not installed');
|
|
41
|
+
var root = ${selector ? `document.querySelector(${JSON.stringify(selector)})` : 'document.documentElement'};
|
|
42
|
+
if (!root) throw new Error('paint target not found: ' + ${JSON.stringify(selector ?? 'document')});
|
|
43
|
+
var mark = { startedAt: performance.now(), mutations: 0, lastMutationAt: performance.now() };
|
|
44
|
+
mark.observer = new MutationObserver(function (records) {
|
|
45
|
+
mark.mutations += records.length;
|
|
46
|
+
mark.lastMutationAt = performance.now();
|
|
47
|
+
});
|
|
48
|
+
mark.observer.observe(root, { subtree: true, childList: true, characterData: true, attributes: true });
|
|
49
|
+
ledger.marks.set(${JSON.stringify(requestId)}, mark);
|
|
50
|
+
return true;
|
|
51
|
+
})();
|
|
52
|
+
`;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Resolve once the target subtree has been quiet for `settleMs` and two
|
|
56
|
+
* animation frames have elapsed. Falls back to a timer only if the document is
|
|
57
|
+
* hidden, where the browser throttles rAF indefinitely — reported honestly via
|
|
58
|
+
* `animationFrames: false` rather than silently producing a number.
|
|
59
|
+
*/
|
|
60
|
+
export function awaitPaintScript(requestId, settleMs, timeoutMs) {
|
|
61
|
+
return `
|
|
62
|
+
(function () {
|
|
63
|
+
var done = arguments[arguments.length - 1];
|
|
64
|
+
var ledger = window.${LEDGER};
|
|
65
|
+
var mark = ledger && ledger.marks.get(${JSON.stringify(requestId)});
|
|
66
|
+
if (!mark) { done({ error: 'no paint mark for request ${requestId}' }); return; }
|
|
67
|
+
var deadline = performance.now() + ${timeoutMs};
|
|
68
|
+
|
|
69
|
+
function finish(animationFrames) {
|
|
70
|
+
mark.observer.disconnect();
|
|
71
|
+
ledger.marks.delete(${JSON.stringify(requestId)});
|
|
72
|
+
done({
|
|
73
|
+
durationMs: performance.now() - mark.startedAt,
|
|
74
|
+
settleMs: mark.lastMutationAt - mark.startedAt,
|
|
75
|
+
mutations: mark.mutations,
|
|
76
|
+
animationFrames: animationFrames,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function afterSettle() {
|
|
81
|
+
if (document.hidden) { finish(false); return; }
|
|
82
|
+
requestAnimationFrame(function () { requestAnimationFrame(function () { finish(true); }); });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
(function poll() {
|
|
86
|
+
var quietFor = performance.now() - mark.lastMutationAt;
|
|
87
|
+
if (quietFor >= ${settleMs}) { afterSettle(); return; }
|
|
88
|
+
if (performance.now() >= deadline) {
|
|
89
|
+
mark.observer.disconnect();
|
|
90
|
+
ledger.marks.delete(${JSON.stringify(requestId)});
|
|
91
|
+
done({ error: 'paint did not settle within ${timeoutMs}ms; ' + mark.mutations + ' mutations, last ' + Math.round(quietFor) + 'ms ago' });
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
setTimeout(poll, 16);
|
|
95
|
+
})();
|
|
96
|
+
})();
|
|
97
|
+
`;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Run `trigger` and return when the resulting render has actually been painted.
|
|
101
|
+
* The mark is installed BEFORE the trigger so no mutation can slip between them.
|
|
102
|
+
*/
|
|
103
|
+
export async function measurePaint(browser, trigger, options) {
|
|
104
|
+
const settleMs = options.settleMs ?? DEFAULT_SETTLE_MS;
|
|
105
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_PAINT_TIMEOUT_MS;
|
|
106
|
+
await browser.execute(installPaintLedgerScript());
|
|
107
|
+
await browser.execute(beginPaintScript(options.requestId, options.selector));
|
|
108
|
+
await trigger();
|
|
109
|
+
const result = await browser.executeAsync(awaitPaintScript(options.requestId, settleMs, timeoutMs));
|
|
110
|
+
if (!result || result.error) {
|
|
111
|
+
throw new Error(`paint measurement failed for ${options.requestId}: ${result?.error ?? 'no result'}`);
|
|
112
|
+
}
|
|
113
|
+
return {
|
|
114
|
+
requestId: options.requestId,
|
|
115
|
+
durationMs: Number(result.durationMs),
|
|
116
|
+
settleMs: Number(result.settleMs),
|
|
117
|
+
mutations: Number(result.mutations ?? 0),
|
|
118
|
+
animationFrames: result.animationFrames === true,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Assert a paint completed within budget. Separate from measurement so a spec can
|
|
123
|
+
* record a timing without failing on it, and so the failure text carries the
|
|
124
|
+
* evidence rather than just a boolean.
|
|
125
|
+
*/
|
|
126
|
+
export function assertPaintWithin(metrics, budgetMs, label) {
|
|
127
|
+
if (!Number.isFinite(budgetMs) || budgetMs <= 0)
|
|
128
|
+
throw new Error('paint budget must be a positive number of milliseconds');
|
|
129
|
+
if (metrics.durationMs > budgetMs) {
|
|
130
|
+
throw new Error(`${label} painted in ${Math.round(metrics.durationMs)}ms, over its ${budgetMs}ms budget `
|
|
131
|
+
+ `(${metrics.mutations} DOM mutations, settled after ${Math.round(metrics.settleMs)}ms, requestId ${metrics.requestId})`);
|
|
132
|
+
}
|
|
133
|
+
return metrics;
|
|
134
|
+
}
|
package/dist/panes.d.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export interface QaGeometryBrowser {
|
|
2
|
+
execute<T>(script: string, ...args: unknown[]): Promise<T>;
|
|
3
|
+
}
|
|
4
|
+
export interface PaneGeometry {
|
|
5
|
+
selector: string;
|
|
6
|
+
x: number;
|
|
7
|
+
y: number;
|
|
8
|
+
width: number;
|
|
9
|
+
height: number;
|
|
10
|
+
visible: boolean;
|
|
11
|
+
active: boolean;
|
|
12
|
+
matches: number;
|
|
13
|
+
viewport: {
|
|
14
|
+
width: number;
|
|
15
|
+
height: number;
|
|
16
|
+
devicePixelRatio: number;
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
export declare function readPaneGeometryScript(selector: string): string;
|
|
20
|
+
export declare function readPaneGeometry(browser: QaGeometryBrowser, selector: string): Promise<PaneGeometry>;
|
|
21
|
+
/**
|
|
22
|
+
* The pane under test must be the visible, active one. Both halves matter: a
|
|
23
|
+
* hidden pane fails "visible", and a background tab that happens to be laid out
|
|
24
|
+
* fails "active".
|
|
25
|
+
*/
|
|
26
|
+
export declare function assertActivePane(geometry: PaneGeometry): PaneGeometry;
|
|
27
|
+
export interface ResizeSettleOptions {
|
|
28
|
+
timeoutMs?: number;
|
|
29
|
+
pollIntervalMs?: number;
|
|
30
|
+
/** Consecutive identical readings required before the box counts as settled. */
|
|
31
|
+
stableReads?: number;
|
|
32
|
+
}
|
|
33
|
+
export declare const DEFAULT_RESIZE_TIMEOUT_MS = 5000;
|
|
34
|
+
/**
|
|
35
|
+
* Wait until the ACTIVE pane's box has changed from `before` and then held still.
|
|
36
|
+
* Bounded: a resize the app never applies fails as a timeout naming both boxes,
|
|
37
|
+
* instead of hanging on an RPC that will not answer.
|
|
38
|
+
*/
|
|
39
|
+
export declare function waitForPaneResize(browser: QaGeometryBrowser, selector: string, before: Pick<PaneGeometry, 'width' | 'height'>, options?: ResizeSettleOptions): Promise<PaneGeometry>;
|
package/dist/panes.js
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// Assert against the pane the user is actually looking at.
|
|
2
|
+
//
|
|
3
|
+
// Tab and split-view UIs keep inactive panes in the DOM — hidden, zero-sized, or
|
|
4
|
+
// positioned offscreen. A selector that matches "the editor pane" therefore
|
|
5
|
+
// resolves just as happily to a background tab, and the resulting assertion
|
|
6
|
+
// describes a pane nobody can see. Worse, it is stable: it passes every run, for
|
|
7
|
+
// the wrong pane, forever.
|
|
8
|
+
//
|
|
9
|
+
// So geometry is read together with visibility and activeness, and a resize is
|
|
10
|
+
// only considered complete once the ACTIVE pane's box has actually changed and
|
|
11
|
+
// settled. Waiting on the window size alone reports success while the pane
|
|
12
|
+
// inside it is still laid out at its old dimensions.
|
|
13
|
+
export function readPaneGeometryScript(selector) {
|
|
14
|
+
return `
|
|
15
|
+
(function () {
|
|
16
|
+
var nodes = document.querySelectorAll(${JSON.stringify(selector)});
|
|
17
|
+
if (nodes.length === 0) return null;
|
|
18
|
+
// Prefer the node the UI marks as current; fall back to the sole match.
|
|
19
|
+
var chosen = null;
|
|
20
|
+
for (var i = 0; i < nodes.length; i += 1) {
|
|
21
|
+
var node = nodes[i];
|
|
22
|
+
if (node.getAttribute('aria-selected') === 'true'
|
|
23
|
+
|| node.getAttribute('aria-current') === 'true'
|
|
24
|
+
|| node.dataset.active === 'true'
|
|
25
|
+
|| node.classList.contains('active')) { chosen = node; break; }
|
|
26
|
+
}
|
|
27
|
+
if (!chosen) chosen = nodes[0];
|
|
28
|
+
var rect = chosen.getBoundingClientRect();
|
|
29
|
+
var style = getComputedStyle(chosen);
|
|
30
|
+
var hiddenByStyle = style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity) === 0;
|
|
31
|
+
var hiddenByAttr = chosen.hasAttribute('hidden') || chosen.getAttribute('aria-hidden') === 'true';
|
|
32
|
+
return {
|
|
33
|
+
x: rect.x, y: rect.y, width: rect.width, height: rect.height,
|
|
34
|
+
visible: !hiddenByStyle && !hiddenByAttr && rect.width > 0 && rect.height > 0,
|
|
35
|
+
active: chosen.getAttribute('aria-selected') === 'true'
|
|
36
|
+
|| chosen.getAttribute('aria-current') === 'true'
|
|
37
|
+
|| chosen.dataset.active === 'true'
|
|
38
|
+
|| chosen.classList.contains('active')
|
|
39
|
+
|| (nodes.length === 1 && !hiddenByStyle && !hiddenByAttr),
|
|
40
|
+
matches: nodes.length,
|
|
41
|
+
viewport: { width: window.innerWidth, height: window.innerHeight, devicePixelRatio: window.devicePixelRatio },
|
|
42
|
+
};
|
|
43
|
+
})();
|
|
44
|
+
`;
|
|
45
|
+
}
|
|
46
|
+
export async function readPaneGeometry(browser, selector) {
|
|
47
|
+
const raw = await browser.execute(readPaneGeometryScript(selector));
|
|
48
|
+
if (!raw)
|
|
49
|
+
throw new Error(`pane selector matched no element: ${selector}`);
|
|
50
|
+
return { selector, ...raw };
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* The pane under test must be the visible, active one. Both halves matter: a
|
|
54
|
+
* hidden pane fails "visible", and a background tab that happens to be laid out
|
|
55
|
+
* fails "active".
|
|
56
|
+
*/
|
|
57
|
+
export function assertActivePane(geometry) {
|
|
58
|
+
if (!geometry.visible) {
|
|
59
|
+
throw new Error(`${geometry.selector} is not visible (${geometry.width}x${geometry.height} at ${geometry.x},${geometry.y}); `
|
|
60
|
+
+ 'the assertion would describe a pane the user cannot see');
|
|
61
|
+
}
|
|
62
|
+
if (!geometry.active) {
|
|
63
|
+
throw new Error(`${geometry.selector} matched ${geometry.matches} panes and the chosen one is not the active pane; `
|
|
64
|
+
+ 'scope the selector to the active tab before asserting');
|
|
65
|
+
}
|
|
66
|
+
return geometry;
|
|
67
|
+
}
|
|
68
|
+
export const DEFAULT_RESIZE_TIMEOUT_MS = 5_000;
|
|
69
|
+
/**
|
|
70
|
+
* Wait until the ACTIVE pane's box has changed from `before` and then held still.
|
|
71
|
+
* Bounded: a resize the app never applies fails as a timeout naming both boxes,
|
|
72
|
+
* instead of hanging on an RPC that will not answer.
|
|
73
|
+
*/
|
|
74
|
+
export async function waitForPaneResize(browser, selector, before, options = {}) {
|
|
75
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_RESIZE_TIMEOUT_MS;
|
|
76
|
+
const pollIntervalMs = options.pollIntervalMs ?? 50;
|
|
77
|
+
const stableReads = options.stableReads ?? 2;
|
|
78
|
+
const deadline = Date.now() + timeoutMs;
|
|
79
|
+
let stable = 0;
|
|
80
|
+
let previous = null;
|
|
81
|
+
let latest = null;
|
|
82
|
+
while (Date.now() < deadline) {
|
|
83
|
+
latest = assertActivePane(await readPaneGeometry(browser, selector));
|
|
84
|
+
const changed = latest.width !== before.width || latest.height !== before.height;
|
|
85
|
+
const held = previous !== null && previous.width === latest.width && previous.height === latest.height;
|
|
86
|
+
if (changed && held) {
|
|
87
|
+
stable += 1;
|
|
88
|
+
if (stable >= stableReads)
|
|
89
|
+
return latest;
|
|
90
|
+
}
|
|
91
|
+
else if (!held) {
|
|
92
|
+
stable = 0;
|
|
93
|
+
}
|
|
94
|
+
previous = latest;
|
|
95
|
+
await delay(pollIntervalMs);
|
|
96
|
+
}
|
|
97
|
+
throw new Error(`${selector} did not settle at a new size within ${timeoutMs}ms: `
|
|
98
|
+
+ `before ${before.width}x${before.height}, last ${latest ? `${latest.width}x${latest.height}` : 'unreadable'}`);
|
|
99
|
+
}
|
|
100
|
+
async function delay(ms) {
|
|
101
|
+
await new Promise((resolve) => setTimeout(resolve, ms));
|
|
102
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
export interface QaWorkspace {
|
|
2
|
+
runId: string;
|
|
3
|
+
root: string;
|
|
4
|
+
/** Isolated user-data directory; pass to the app so it takes its own lock. */
|
|
5
|
+
dataDir: string;
|
|
6
|
+
/** Somewhere for screenshots, logs, and other recorded evidence. */
|
|
7
|
+
evidenceDir: string;
|
|
8
|
+
/** Environment overrides that point the app at this workspace. */
|
|
9
|
+
env: Record<string, string>;
|
|
10
|
+
}
|
|
11
|
+
export interface DisposeReport {
|
|
12
|
+
removed: string[];
|
|
13
|
+
retained: Array<{
|
|
14
|
+
path: string;
|
|
15
|
+
reason: string;
|
|
16
|
+
}>;
|
|
17
|
+
}
|
|
18
|
+
export interface CreateQaWorkspaceOptions {
|
|
19
|
+
root: string;
|
|
20
|
+
runId?: string;
|
|
21
|
+
appKey: string;
|
|
22
|
+
}
|
|
23
|
+
export declare function createQaWorkspace(options: CreateQaWorkspaceOptions): QaWorkspace;
|
|
24
|
+
export interface DisposeOptions {
|
|
25
|
+
/** Keep the recorded evidence and remove only the volatile profile. */
|
|
26
|
+
keepEvidence?: boolean;
|
|
27
|
+
attempts?: number;
|
|
28
|
+
retryDelayMs?: number;
|
|
29
|
+
sleep?: (ms: number) => void;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Remove the workspace, retrying through the brief window in which an exiting
|
|
33
|
+
* app still holds its files open. Anything still present afterwards is returned,
|
|
34
|
+
* never silently ignored.
|
|
35
|
+
*/
|
|
36
|
+
export declare function disposeQaWorkspace(workspace: QaWorkspace, options?: DisposeOptions): DisposeReport;
|
|
37
|
+
/**
|
|
38
|
+
* Sweep run directories left behind by earlier crashed runs. Bounded to the
|
|
39
|
+
* workspace's own `runs` directory and to entries older than `olderThanMs`, so a
|
|
40
|
+
* concurrent run is never removed out from under itself.
|
|
41
|
+
*/
|
|
42
|
+
export declare function pruneAbandonedWorkspaces(root: string, { olderThanMs, now, keep }?: {
|
|
43
|
+
olderThanMs?: number;
|
|
44
|
+
now?: number;
|
|
45
|
+
keep?: string[];
|
|
46
|
+
}): DisposeReport;
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// An isolated, reliably-removable home for one QA run.
|
|
2
|
+
//
|
|
3
|
+
// Two failures this addresses:
|
|
4
|
+
//
|
|
5
|
+
// Single instance. The apps hold a single-instance lock keyed on their user
|
|
6
|
+
// data directory. A harness that launches the app with the real data directory
|
|
7
|
+
// either loses to an already-running copy (the launch silently hands off to the
|
|
8
|
+
// existing window and the driver attaches to nothing) or steals the user's
|
|
9
|
+
// session. Giving the run its own data directory makes the harness a genuinely
|
|
10
|
+
// separate instance instead of a competitor for the same lock.
|
|
11
|
+
//
|
|
12
|
+
// Cleanup. Best-effort `rm -rf` in a finally block is not rollback. On Windows a
|
|
13
|
+
// file still mapped by an exiting process fails with EBUSY/EPERM for a short
|
|
14
|
+
// window, so a single attempt leaves directories behind — which is exactly the
|
|
15
|
+
// litter found in packages/legal-ui/.cache. Removal retries, and whatever
|
|
16
|
+
// genuinely could not be removed is REPORTED rather than swallowed.
|
|
17
|
+
import crypto from 'node:crypto';
|
|
18
|
+
import fs from 'node:fs';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
export function createQaWorkspace(options) {
|
|
21
|
+
const appKey = requireText(options.appKey, 'appKey');
|
|
22
|
+
const runId = options.runId ?? crypto.randomUUID();
|
|
23
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(runId))
|
|
24
|
+
throw new Error(`unsafe QA run id: ${runId}`);
|
|
25
|
+
const root = path.resolve(requireText(options.root, 'root'), 'runs', runId);
|
|
26
|
+
const dataDir = path.join(root, 'data');
|
|
27
|
+
const evidenceDir = path.join(root, 'evidence');
|
|
28
|
+
fs.mkdirSync(dataDir, { recursive: true });
|
|
29
|
+
fs.mkdirSync(evidenceDir, { recursive: true });
|
|
30
|
+
return {
|
|
31
|
+
runId,
|
|
32
|
+
root,
|
|
33
|
+
dataDir,
|
|
34
|
+
evidenceDir,
|
|
35
|
+
env: {
|
|
36
|
+
RIGHTKIT_QA_NATIVE: '1',
|
|
37
|
+
RIGHTKIT_QA_RUN_ID: runId,
|
|
38
|
+
RIGHTKIT_QA_DATA_DIR: dataDir,
|
|
39
|
+
RIGHTKIT_QA_EVIDENCE_DIR: evidenceDir,
|
|
40
|
+
// Tauri/webview honour these for profile isolation; setting all three keeps
|
|
41
|
+
// the run off the user's real profile regardless of which the app reads.
|
|
42
|
+
XDG_DATA_HOME: dataDir,
|
|
43
|
+
XDG_CONFIG_HOME: dataDir,
|
|
44
|
+
WEBVIEW2_USER_DATA_FOLDER: path.join(dataDir, 'webview2'),
|
|
45
|
+
[`${appKey.toUpperCase().replace(/[^A-Z0-9]/g, '_')}_DATA_DIR`]: dataDir,
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Remove the workspace, retrying through the brief window in which an exiting
|
|
51
|
+
* app still holds its files open. Anything still present afterwards is returned,
|
|
52
|
+
* never silently ignored.
|
|
53
|
+
*/
|
|
54
|
+
export function disposeQaWorkspace(workspace, options = {}) {
|
|
55
|
+
const attempts = Math.max(1, options.attempts ?? 5);
|
|
56
|
+
const retryDelayMs = options.retryDelayMs ?? 200;
|
|
57
|
+
const sleep = options.sleep ?? sleepSync;
|
|
58
|
+
const targets = options.keepEvidence ? [workspace.dataDir] : [workspace.root];
|
|
59
|
+
const report = { removed: [], retained: [] };
|
|
60
|
+
for (const target of targets) {
|
|
61
|
+
// Only ever remove inside the workspace root we created.
|
|
62
|
+
const resolved = path.resolve(target);
|
|
63
|
+
if (resolved !== path.resolve(workspace.root) && !isInside(workspace.root, resolved)) {
|
|
64
|
+
report.retained.push({ path: resolved, reason: 'outside the QA workspace root' });
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
if (!fs.existsSync(resolved)) {
|
|
68
|
+
report.removed.push(resolved);
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
let lastError;
|
|
72
|
+
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
|
73
|
+
try {
|
|
74
|
+
fs.rmSync(resolved, { recursive: true, force: true });
|
|
75
|
+
lastError = undefined;
|
|
76
|
+
break;
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
lastError = error;
|
|
80
|
+
if (attempt < attempts)
|
|
81
|
+
sleep(retryDelayMs);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
if (lastError === undefined && !fs.existsSync(resolved))
|
|
85
|
+
report.removed.push(resolved);
|
|
86
|
+
else
|
|
87
|
+
report.retained.push({ path: resolved, reason: describeError(lastError) });
|
|
88
|
+
}
|
|
89
|
+
return report;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Sweep run directories left behind by earlier crashed runs. Bounded to the
|
|
93
|
+
* workspace's own `runs` directory and to entries older than `olderThanMs`, so a
|
|
94
|
+
* concurrent run is never removed out from under itself.
|
|
95
|
+
*/
|
|
96
|
+
export function pruneAbandonedWorkspaces(root, { olderThanMs = 6 * 60 * 60_000, now = Date.now(), keep = [] } = {}) {
|
|
97
|
+
const runsRoot = path.resolve(root, 'runs');
|
|
98
|
+
const report = { removed: [], retained: [] };
|
|
99
|
+
let entries;
|
|
100
|
+
try {
|
|
101
|
+
entries = fs.readdirSync(runsRoot, { withFileTypes: true });
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
return report;
|
|
105
|
+
}
|
|
106
|
+
for (const entry of entries) {
|
|
107
|
+
if (!entry.isDirectory() || entry.isSymbolicLink())
|
|
108
|
+
continue;
|
|
109
|
+
if (keep.includes(entry.name))
|
|
110
|
+
continue;
|
|
111
|
+
const target = path.join(runsRoot, entry.name);
|
|
112
|
+
let ageMs;
|
|
113
|
+
try {
|
|
114
|
+
ageMs = now - fs.statSync(target).mtimeMs;
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
if (ageMs < olderThanMs) {
|
|
120
|
+
report.retained.push({ path: target, reason: `only ${Math.round(ageMs / 1000)}s old; may belong to a live run` });
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
try {
|
|
124
|
+
fs.rmSync(target, { recursive: true, force: true });
|
|
125
|
+
report.removed.push(target);
|
|
126
|
+
}
|
|
127
|
+
catch (error) {
|
|
128
|
+
report.retained.push({ path: target, reason: describeError(error) });
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return report;
|
|
132
|
+
}
|
|
133
|
+
function isInside(root, candidate) {
|
|
134
|
+
const relative = path.relative(path.resolve(root), path.resolve(candidate));
|
|
135
|
+
return relative !== '' && !relative.startsWith('..') && !path.isAbsolute(relative);
|
|
136
|
+
}
|
|
137
|
+
function describeError(error) {
|
|
138
|
+
if (error === undefined)
|
|
139
|
+
return 'still present after removal reported success';
|
|
140
|
+
const code = error.code;
|
|
141
|
+
return code ? `${code}: ${error.message}` : String(error?.message ?? error);
|
|
142
|
+
}
|
|
143
|
+
function sleepSync(ms) {
|
|
144
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
145
|
+
}
|
|
146
|
+
function requireText(value, name) {
|
|
147
|
+
const normalized = String(value ?? '').trim();
|
|
148
|
+
if (!normalized)
|
|
149
|
+
throw new Error(`${name} is required`);
|
|
150
|
+
return normalized;
|
|
151
|
+
}
|
package/package.json
CHANGED