@specific.dev/spectest 0.126.0 → 0.128.0
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/android-step.d.ts +7 -0
- package/dist/android.d.ts +17 -3
- package/dist/android.js +122 -26
- package/dist/components/android.d.ts +1 -1
- package/dist/components/android.js +1 -1
- package/dist/components/k3s-coverage.d.ts +24 -0
- package/dist/components/k3s-coverage.js +259 -0
- package/dist/components/k3s-trust.d.ts +4 -2
- package/dist/components/k3s-trust.js +13 -0
- package/dist/components/k3s.js +31 -0
- package/dist/coverage/rust.rs +102 -0
- package/dist/coverage-bun.d.ts +10 -0
- package/dist/coverage-bun.js +32 -0
- package/dist/coverage-rust.d.ts +23 -0
- package/dist/coverage-rust.js +71 -0
- package/dist/coverage.d.ts +11 -5
- package/dist/coverage.js +70 -13
- package/dist/daemon.js +27 -2
- package/dist/harness/component-images.d.ts +1 -0
- package/dist/harness/component-images.js +4 -2
- package/dist/harness/coverage-target.d.ts +10 -0
- package/dist/harness/coverage-target.js +54 -0
- package/dist/index.d.ts +21 -1
- package/dist/index.js +34 -1
- package/package.json +1 -1
- package/src/android-actions.test.ts +121 -0
- package/src/android-step.ts +2 -0
- package/src/android.test.ts +3 -0
- package/src/android.ts +107 -33
- package/src/components/android.test.ts +1 -0
- package/src/components/android.ts +2 -2
- package/src/components/k3s-coverage.test.ts +160 -0
- package/src/components/k3s-coverage.ts +234 -0
- package/src/components/k3s-trust.ts +14 -2
- package/src/components/k3s.ts +26 -0
- package/src/coverage/rust.rs +102 -0
- package/src/coverage-bun.test.ts +103 -0
- package/src/coverage-bun.ts +40 -0
- package/src/coverage-rust.test.ts +74 -0
- package/src/coverage-rust.ts +79 -0
- package/src/coverage.test.ts +4 -4
- package/src/coverage.ts +74 -13
- package/src/daemon.ts +22 -2
- package/src/harness/component-images.ts +5 -2
- package/src/harness/coverage-target.ts +55 -0
- package/src/index.ts +44 -4
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// Runtime adapters operate on this same context in Docker and nested runtimes.
|
|
2
|
+
// Counter baselines belong to an instance; source identity belongs to its image.
|
|
3
|
+
import { promises as fs } from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { attachCoverageSourceOptions, coverageAdapters, coverageReportsMode, resolveCoverageSourceOptions } from "../coverage.js";
|
|
6
|
+
import { applyCoverageDelta, readCoverageDir } from "./coverage.js";
|
|
7
|
+
export async function captureCoverageTarget(options) {
|
|
8
|
+
const { context: ctx, coverage, root, baseline } = options;
|
|
9
|
+
const adapters = coverageAdapters(coverage);
|
|
10
|
+
const written = new Set();
|
|
11
|
+
const delta = new Set();
|
|
12
|
+
const context = { ...ctx, async writeReport(name, content) {
|
|
13
|
+
const resolved = path.resolve(ctx.reportDir, name);
|
|
14
|
+
if (!resolved.startsWith(path.resolve(ctx.reportDir) + path.sep))
|
|
15
|
+
throw new Error("coverage report escapes directory");
|
|
16
|
+
await fs.mkdir(path.dirname(resolved), { recursive: true });
|
|
17
|
+
await fs.writeFile(resolved + ".tmp", content);
|
|
18
|
+
await fs.rename(resolved + ".tmp", resolved);
|
|
19
|
+
written.add(name);
|
|
20
|
+
} };
|
|
21
|
+
try {
|
|
22
|
+
ctx.signal.throwIfAborted();
|
|
23
|
+
const policies = await resolveCoverageSourceOptions(adapters, ctx, typeof coverage === "object" ? {
|
|
24
|
+
sourceRoot: coverage.sourceRoot, excludeSources: coverage.excludeSources,
|
|
25
|
+
} : undefined);
|
|
26
|
+
for (const adapter of adapters) {
|
|
27
|
+
if (adapter.collect || adapter.load)
|
|
28
|
+
throw new Error(`adapter ${adapter.name} cannot run inside a container; attach browser coverage to the cluster service`);
|
|
29
|
+
written.clear();
|
|
30
|
+
await adapter.capture?.(context);
|
|
31
|
+
if (adapter.reports === "delta")
|
|
32
|
+
for (const name of written)
|
|
33
|
+
delta.add(name);
|
|
34
|
+
}
|
|
35
|
+
if (baseline && options.inventoryAvailable !== false) {
|
|
36
|
+
const units = new Set();
|
|
37
|
+
for (const adapter of adapters)
|
|
38
|
+
for (const unit of await adapter.inventory?.(context) ?? [])
|
|
39
|
+
units.add(unit);
|
|
40
|
+
await context.writeReport("inventory.units", [...units].sort().join("\n"));
|
|
41
|
+
}
|
|
42
|
+
ctx.signal.throwIfAborted();
|
|
43
|
+
const capture = await readCoverageDir(ctx.service, ctx.reportDir);
|
|
44
|
+
if (baseline)
|
|
45
|
+
await fs.rm(path.join(ctx.reportDir, "inventory.units"), { force: true });
|
|
46
|
+
capture.root = root;
|
|
47
|
+
attachCoverageSourceOptions(capture, policies);
|
|
48
|
+
const result = applyCoverageDelta({ ...capture, service: options.counterKey ?? capture.service }, coverageReportsMode(coverage), delta);
|
|
49
|
+
return { ...result, service: capture.service };
|
|
50
|
+
}
|
|
51
|
+
catch (error) {
|
|
52
|
+
return { service: ctx.service, reports: [], error: String(error) };
|
|
53
|
+
}
|
|
54
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -4,7 +4,7 @@ import type { InjectRule, SignConfig } from "./fake-upstream.js";
|
|
|
4
4
|
export type { InjectRule, InjectMatch, StringMatch, SignConfig, AwsSigV4Sign } from "./fake-upstream.js";
|
|
5
5
|
export type { Phone, PhoneOptions, CallOptions, SpeechOptions } from "./phone.js";
|
|
6
6
|
import type { Phone, PhoneOptions } from "./phone.js";
|
|
7
|
-
import type
|
|
7
|
+
import { type Android, type AndroidApp, type AndroidElement } from "./android.js";
|
|
8
8
|
import { type InteractiveEntrypoint, type InteractiveEntrypoints } from "./interactive.js";
|
|
9
9
|
export type { InteractiveEntrypoint, InteractiveEntrypoints } from "./interactive.js";
|
|
10
10
|
export type { Android, AndroidApp, AndroidElement, AndroidRecordingOptions } from "./android.js";
|
|
@@ -1959,9 +1959,29 @@ export interface BrowserAssertion extends BrowserMatchers {
|
|
|
1959
1959
|
/** Negate every matcher (retries until the negated condition holds). */
|
|
1960
1960
|
not: BrowserMatchers;
|
|
1961
1961
|
}
|
|
1962
|
+
export declare function expect(actual: AndroidElement, message?: string): AndroidAssertion;
|
|
1962
1963
|
export declare function expect(actual: Locator, message?: string): LocatorAssertion;
|
|
1963
1964
|
export declare function expect(actual: Browser, message?: string): BrowserAssertion;
|
|
1964
1965
|
export declare function expect(actual: Provenanced, message?: string): Expectation;
|
|
1966
|
+
/** Retrying native Android assertions. Timeout is in milliseconds (default 10 s;
|
|
1967
|
+
* zero performs one observation). Each assertion records one Android step. */
|
|
1968
|
+
export interface AndroidMatchers {
|
|
1969
|
+
toBeVisible(options?: {
|
|
1970
|
+
timeout?: number;
|
|
1971
|
+
}): Promise<void>;
|
|
1972
|
+
toBeHidden(options?: {
|
|
1973
|
+
timeout?: number;
|
|
1974
|
+
}): Promise<void>;
|
|
1975
|
+
toHaveText(expected: string | RegExp, options?: {
|
|
1976
|
+
timeout?: number;
|
|
1977
|
+
}): Promise<void>;
|
|
1978
|
+
toContainText(expected: string | RegExp, options?: {
|
|
1979
|
+
timeout?: number;
|
|
1980
|
+
}): Promise<void>;
|
|
1981
|
+
}
|
|
1982
|
+
export interface AndroidAssertion extends AndroidMatchers {
|
|
1983
|
+
not: AndroidMatchers;
|
|
1984
|
+
}
|
|
1965
1985
|
/**
|
|
1966
1986
|
* Assert on a value with **no provenance** — a computed number, a frame read
|
|
1967
1987
|
* off a raw `WebSocket` you opened yourself, anything that never flowed from a
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { validateServiceNetwork } from "./harness/service-network.js";
|
|
2
2
|
import { stripVTControlCharacters } from "node:util";
|
|
3
|
+
import { ANDROID_ASSERT } from "./android.js";
|
|
3
4
|
import { resolveEntrypoints } from "./interactive.js";
|
|
4
5
|
// Spectest SDK. The user's single `spectest/index.ts` calls
|
|
5
6
|
// `defineEnvironment({ name, services })` once, defines test cases via
|
|
@@ -40,7 +41,7 @@ export { McpHttpError, McpRpcError, McpAuthDeniedError, } from "./mcp.js";
|
|
|
40
41
|
import { isLocator, getLocatorProbe, isBrowserSession, getBrowserProbe, } from "./locator.js";
|
|
41
42
|
import { escapeInvisible, textDifferenceNote, } from "./text-match.js";
|
|
42
43
|
import { describeUrlPattern } from "./url-match.js";
|
|
43
|
-
import { applyCoverageAdapters, validateCoverage } from "./coverage.js";
|
|
44
|
+
import { applyCoverageAdapters, coverageAdapters, validateCoverage } from "./coverage.js";
|
|
44
45
|
import { resolveExistingProjectPath } from "./project-files.js";
|
|
45
46
|
export { ca } from "./environment-ca.js";
|
|
46
47
|
// Low-level ingress primitives + the framework lowering that the friendly
|
|
@@ -184,6 +185,15 @@ function expandServiceGroups(input) {
|
|
|
184
185
|
// Rewrite relative dependsOn to final keys; a spread keeps function
|
|
185
186
|
// fields (helpers/setup) and the enumerable-symbol ingress decls.
|
|
186
187
|
const final = { ...svc, ...(part === entry.primary ? primaryOverrides : {}) };
|
|
188
|
+
// Component-owned nested containers keep their collection when callers
|
|
189
|
+
// add ordinary service coverage (for example browser coverage on k3s).
|
|
190
|
+
const collectors = coverageAdapters(svc.coverage).filter(adapter => adapter.collect);
|
|
191
|
+
if (part === entry.primary && collectors.length && primaryOverrides.coverage !== undefined) {
|
|
192
|
+
const extra = primaryOverrides.coverage;
|
|
193
|
+
final.coverage = { ...(typeof extra === "object" ? extra : {}), adapters: [
|
|
194
|
+
...collectors, ...coverageAdapters(extra).filter(adapter => !collectors.includes(adapter)),
|
|
195
|
+
] };
|
|
196
|
+
}
|
|
187
197
|
if (final.dependsOn?.length) {
|
|
188
198
|
final.dependsOn = final.dependsOn.map((d) => partKeys.has(d) ? naming.key(d) : d);
|
|
189
199
|
}
|
|
@@ -663,12 +673,35 @@ export class ExpectationError extends Error {
|
|
|
663
673
|
}
|
|
664
674
|
}
|
|
665
675
|
export function expect(actual, message) {
|
|
676
|
+
if (actual && typeof actual === "object" && ANDROID_ASSERT in actual)
|
|
677
|
+
return buildAndroidAssertion(actual, message);
|
|
666
678
|
if (isLocator(actual))
|
|
667
679
|
return buildLocatorAssertion(actual, message);
|
|
668
680
|
if (isBrowserSession(actual))
|
|
669
681
|
return buildBrowserAssertion(actual, message);
|
|
670
682
|
return expectValue(actual, message);
|
|
671
683
|
}
|
|
684
|
+
function buildAndroidAssertion(element, message) {
|
|
685
|
+
const matchers = (negated) => {
|
|
686
|
+
const run = async (matcher, expected, timeout) => {
|
|
687
|
+
const result = await element[ANDROID_ASSERT](matcher, negated, expected, timeout);
|
|
688
|
+
const error = result.error === undefined ? undefined : new ExpectationError(String(result.error));
|
|
689
|
+
if (error)
|
|
690
|
+
error.cause = result.error;
|
|
691
|
+
recordAssertion({ matcher, negated, passed: !error, message, sourceSeq: result.sourceSeq,
|
|
692
|
+
actual: safeSerialize(result.actual), expected: expected === undefined ? undefined : safeSerialize(assertionExpected(expected)), error: error?.message });
|
|
693
|
+
if (error)
|
|
694
|
+
throw error;
|
|
695
|
+
};
|
|
696
|
+
return {
|
|
697
|
+
toBeVisible: options => run("toBeVisible", undefined, options?.timeout),
|
|
698
|
+
toBeHidden: options => run("toBeHidden", undefined, options?.timeout),
|
|
699
|
+
toHaveText: (expected, options) => run("toHaveText", expected, options?.timeout),
|
|
700
|
+
toContainText: (expected, options) => run("toContainText", expected, options?.timeout),
|
|
701
|
+
};
|
|
702
|
+
};
|
|
703
|
+
return Object.assign(matchers(false), { not: matchers(true) });
|
|
704
|
+
}
|
|
672
705
|
function expectValue(actual, message) {
|
|
673
706
|
// The first parameter is typed to the {@link Provenanced} family so a raw
|
|
674
707
|
// value (`expect(res.status === 200)`, `expect(2 + 2)`) is a *compile error* —
|
package/package.json
CHANGED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { afterEach, expect, test } from "bun:test";
|
|
2
|
+
import { androidApp, androidScope } from "./android.js";
|
|
3
|
+
import { expect as assert, ExpectationError } from "./index.js";
|
|
4
|
+
import { startRecording, stopRecording } from "./recorder.js";
|
|
5
|
+
|
|
6
|
+
const cleanups: (() => void)[] = [];
|
|
7
|
+
afterEach(() => { stopRecording(); cleanups.splice(0).forEach(fn => fn()); });
|
|
8
|
+
|
|
9
|
+
async function fixture() {
|
|
10
|
+
let finds = 0, clicks = 0, clears = 0, values = 0;
|
|
11
|
+
let missing = 0, invisible = 0, disabled = 0;
|
|
12
|
+
let text = "Saved", invalid = false, failClick = false;
|
|
13
|
+
const server = Bun.serve({ port: 0, hostname: "127.0.0.1", async fetch(req) {
|
|
14
|
+
const path = new URL(req.url).pathname;
|
|
15
|
+
const value = (value: unknown) => Response.json({ value });
|
|
16
|
+
const error = (error: string) => Response.json({ value: { error, message: error } }, { status: 404 });
|
|
17
|
+
if (path === "/recordings") return value({ startedAtMs: Date.now() });
|
|
18
|
+
if (path === "/session") return value({ sessionId: "test" });
|
|
19
|
+
if (path.endsWith("/element")) {
|
|
20
|
+
finds++;
|
|
21
|
+
if (invalid) return error("invalid selector");
|
|
22
|
+
if (missing-- > 0) return error("no such element");
|
|
23
|
+
return value({ "element-6066-11e4-a52e-4f735466cecf": `e${finds}` });
|
|
24
|
+
}
|
|
25
|
+
if (path.endsWith("/displayed")) return value(!(invisible-- > 0));
|
|
26
|
+
if (path.endsWith("/enabled")) return value(!(disabled-- > 0));
|
|
27
|
+
if (path.endsWith("/window/rect")) return value({ width: 100, height: 200 });
|
|
28
|
+
if (path.endsWith("/rect")) return value({ x: 10, y: 40, width: 80, height: 20 });
|
|
29
|
+
if (path.endsWith("/text")) return value(text);
|
|
30
|
+
if (path.endsWith("/clear")) { clears++; return value(null); }
|
|
31
|
+
if (path.endsWith("/value")) { values++; return value(null); }
|
|
32
|
+
if (path.endsWith("/click")) { clicks++; return failClick ? error("stale element reference") : value(null); }
|
|
33
|
+
return value(null);
|
|
34
|
+
} });
|
|
35
|
+
const endpoint = `http://127.0.0.1:${server.port}`;
|
|
36
|
+
const scope = androidScope(`test-${server.port}`, Date.now());
|
|
37
|
+
cleanups.push(() => { scope.deactivate(); server.stop(true); });
|
|
38
|
+
startRecording();
|
|
39
|
+
const phone = await scope.open(androidApp({ name: "phone", endpoint, recorderEndpoint: endpoint, serial: "device", package: "app", recording: {} }));
|
|
40
|
+
return { scope, phone, stats: () => ({ finds, clicks, clears, values }),
|
|
41
|
+
configure(options: { missing?: number; invisible?: number; disabled?: number; text?: string; invalid?: boolean; failClick?: boolean }) {
|
|
42
|
+
missing = options.missing ?? 0; invisible = options.invisible ?? 0; disabled = options.disabled ?? 0;
|
|
43
|
+
text = options.text ?? text; invalid = options.invalid ?? false; failClick = options.failClick ?? false;
|
|
44
|
+
} };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
test("tap and fill wait for visible enabled elements, recording one step each", async () => {
|
|
48
|
+
const f = await fixture();
|
|
49
|
+
f.configure({ missing: 1, invisible: 1, disabled: 1 });
|
|
50
|
+
await f.phone.getByText("Save").tap({ timeoutMs: 1000 });
|
|
51
|
+
f.configure({ missing: 1 });
|
|
52
|
+
await f.phone.getById("draft").fill("private text", { timeoutMs: 1000 });
|
|
53
|
+
expect(f.stats()).toMatchObject({ clicks: 1, clears: 1, values: 1 });
|
|
54
|
+
const events = stopRecording();
|
|
55
|
+
expect(events).toHaveLength(2);
|
|
56
|
+
expect(events[1]).toMatchObject({ android: { action: "fill", bounds: { x: 0.1, y: 0.2, width: 0.8, height: 0.1 } } });
|
|
57
|
+
expect(JSON.stringify(events)).not.toContain("private text");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("native assertions retry without emitting polling attempts and preserve provenance", async () => {
|
|
61
|
+
const f = await fixture();
|
|
62
|
+
f.configure({ missing: 2 });
|
|
63
|
+
await assert(f.phone.getByText("Saved"), "save completes").toBeVisible({ timeout: 1000 });
|
|
64
|
+
f.configure({ text: "Loading" });
|
|
65
|
+
const change = setTimeout(() => f.configure({ text: " Saved\n successfully " }), 150);
|
|
66
|
+
await assert(f.phone.getById("status")).toHaveText("Saved successfully", { timeout: 1000 });
|
|
67
|
+
clearTimeout(change);
|
|
68
|
+
await assert(f.phone.getById("status")).toContainText(/Saved/g);
|
|
69
|
+
await assert(f.phone.getById("status")).not.toHaveText("Loading");
|
|
70
|
+
f.configure({ missing: 10 });
|
|
71
|
+
await assert(f.phone.getById("gone")).toBeHidden({ timeout: 0 });
|
|
72
|
+
await assert(f.phone.getById("gone")).not.toBeVisible({ timeout: 0 });
|
|
73
|
+
const events = stopRecording();
|
|
74
|
+
const steps = events.filter(e => e.kind === "android");
|
|
75
|
+
const assertions = events.filter(e => e.kind === "assertion");
|
|
76
|
+
expect(steps).toHaveLength(6);
|
|
77
|
+
expect(assertions).toHaveLength(6);
|
|
78
|
+
for (let i = 0; i < steps.length; i++) expect(assertions[i]).toMatchObject({ sourceSeq: steps[i].seq, passed: true });
|
|
79
|
+
expect(steps[0]).toMatchObject({ android: { bounds: { x: 0.1, y: 0.2, width: 0.8, height: 0.1 } } });
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("timeouts and invalid selectors fail once, and dispatched clicks are never retried", async () => {
|
|
83
|
+
const f = await fixture();
|
|
84
|
+
f.configure({ missing: 100 });
|
|
85
|
+
await expect(assert(f.phone.getById("missing")).toBeVisible({ timeout: 0 })).rejects.toBeInstanceOf(ExpectationError);
|
|
86
|
+
expect(f.stats().finds).toBe(1);
|
|
87
|
+
f.configure({ invalid: true });
|
|
88
|
+
await expect(f.phone.getById("bad").tap()).rejects.toThrow("invalid selector");
|
|
89
|
+
expect(f.stats().finds).toBe(2);
|
|
90
|
+
f.configure({ failClick: true });
|
|
91
|
+
await expect(f.phone.getById("save").tap()).rejects.toThrow("stale element reference");
|
|
92
|
+
expect(f.stats().clicks).toBe(1);
|
|
93
|
+
const events = stopRecording();
|
|
94
|
+
expect(events.filter(e => e.kind === "android")).toHaveLength(3);
|
|
95
|
+
expect(events.find(e => e.kind === "assertion")).toMatchObject({ passed: false, sourceSeq: events[0].seq });
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("immediate visibility checks stay immediate; read waits and canceled waits stop", async () => {
|
|
99
|
+
const f = await fixture();
|
|
100
|
+
f.configure({ missing: 100 });
|
|
101
|
+
expect((await f.phone.getById("missing").isDisplayed()).unwrap()).toBe(false);
|
|
102
|
+
expect(f.stats().finds).toBe(1);
|
|
103
|
+
f.configure({ missing: 1 });
|
|
104
|
+
expect((await f.phone.getById("status").text({ timeoutMs: 500 })).unwrap()).toBe("Saved");
|
|
105
|
+
f.configure({ missing: 100 });
|
|
106
|
+
const pending = f.phone.getById("missing").waitFor();
|
|
107
|
+
setTimeout(() => f.scope.deactivate(), 30);
|
|
108
|
+
await expect(pending).rejects.toThrow("finished test");
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("a completed disappearance wait clears the earlier visible target, like browser replay", async () => {
|
|
112
|
+
const f = await fixture();
|
|
113
|
+
const hide = setTimeout(() => f.configure({ missing: 100 }), 50);
|
|
114
|
+
await assert(f.phone.getById("spinner")).toBeHidden({ timeout: 1000 });
|
|
115
|
+
clearTimeout(hide);
|
|
116
|
+
const events = stopRecording();
|
|
117
|
+
const step = events.find(e => e.kind === "android")!;
|
|
118
|
+
expect(step).toMatchObject({ android: { state: "hidden" }, status: "passed" });
|
|
119
|
+
expect(step.android?.bounds).toBeUndefined();
|
|
120
|
+
expect(f.stats().finds).toBeGreaterThan(1);
|
|
121
|
+
});
|
package/src/android-step.ts
CHANGED
|
@@ -18,6 +18,8 @@ export interface AndroidStepDetails {
|
|
|
18
18
|
| "source"
|
|
19
19
|
| "screenshot"
|
|
20
20
|
| "execute";
|
|
21
|
+
/** Normalized screen bounds observed by this operation. */
|
|
22
|
+
bounds?: { x: number; y: number; width: number; height: number };
|
|
21
23
|
target?: AndroidTarget;
|
|
22
24
|
point?: { x: number; y: number };
|
|
23
25
|
direction?: "up" | "down" | "left" | "right";
|
package/src/android.test.ts
CHANGED
|
@@ -57,6 +57,7 @@ describe("Android case lifecycle", () => {
|
|
|
57
57
|
return Response.json({ value: { error: "stale element reference", message: "Gone" } }, { status: 404 });
|
|
58
58
|
if (path.endsWith("/element"))
|
|
59
59
|
return json({ value: { "element-6066-11e4-a52e-4f735466cecf": "element-1" } });
|
|
60
|
+
if (path.endsWith("/displayed") || path.endsWith("/enabled")) return json({ value: true });
|
|
60
61
|
if (path.endsWith("/text")) return json({ value: "inherited draft" });
|
|
61
62
|
if (path.endsWith("/window/rect")) return json({ value: { width: 1080, height: 2400 } });
|
|
62
63
|
if (path.endsWith("/rect")) return json({ value: { x: 100, y: 400, width: 880, height: 160 } });
|
|
@@ -135,6 +136,8 @@ describe("Android case lifecycle", () => {
|
|
|
135
136
|
const starts = requests.filter((r) => r.path === "/session");
|
|
136
137
|
expect(starts).toHaveLength(1);
|
|
137
138
|
expect(starts[0].body.capabilities.alwaysMatch).toMatchObject({
|
|
139
|
+
timeouts: { implicit: 0 },
|
|
140
|
+
"appium:settings[waitForSelectorTimeout]": 0,
|
|
138
141
|
"appium:noReset": true,
|
|
139
142
|
"appium:autoLaunch": false,
|
|
140
143
|
"appium:newCommandTimeout": 0,
|
package/src/android.ts
CHANGED
|
@@ -70,6 +70,10 @@ async function session(app: AndroidApp): Promise<string> {
|
|
|
70
70
|
capabilities: {
|
|
71
71
|
alwaysMatch: {
|
|
72
72
|
platformName: "Android",
|
|
73
|
+
// Conditions are explicit waits. Do not stack a session-wide lookup
|
|
74
|
+
// wait or UiSelector's own 10s wait underneath their deadline.
|
|
75
|
+
timeouts: { implicit: 0 },
|
|
76
|
+
"appium:settings[waitForSelectorTimeout]": 0,
|
|
73
77
|
"appium:automationName": "UiAutomator2",
|
|
74
78
|
"appium:udid": app.serial,
|
|
75
79
|
"appium:appPackage": app.package,
|
|
@@ -93,10 +97,14 @@ async function session(app: AndroidApp): Promise<string> {
|
|
|
93
97
|
return pending;
|
|
94
98
|
}
|
|
95
99
|
|
|
100
|
+
export const ANDROID_ASSERT: unique symbol = Symbol.for("spectest.androidAssert");
|
|
101
|
+
export type AndroidMatcher = "toBeVisible" | "toBeHidden" | "toHaveText" | "toContainText";
|
|
102
|
+
export interface AndroidAssertionResult { sourceSeq?: number; actual?: unknown; error?: unknown }
|
|
96
103
|
export interface AndroidElement {
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
text
|
|
104
|
+
[ANDROID_ASSERT](matcher: AndroidMatcher, negated: boolean, expected: string | RegExp | undefined, timeoutMs?: number): Promise<AndroidAssertionResult>;
|
|
105
|
+
tap(options?: { timeoutMs?: number }): Promise<void>;
|
|
106
|
+
fill(text: string, options?: { timeoutMs?: number }): Promise<void>;
|
|
107
|
+
text(options?: { timeoutMs?: number }): Promise<Wrapped<string>>;
|
|
100
108
|
isDisplayed(): Promise<Wrapped<boolean>>;
|
|
101
109
|
waitFor(options?: { state?: "visible" | "hidden"; timeoutMs?: number }): Promise<void>;
|
|
102
110
|
}
|
|
@@ -227,14 +235,14 @@ export function androidScope(
|
|
|
227
235
|
if (list.length < 10000) list.push(marker);
|
|
228
236
|
taps.set(app.name, list);
|
|
229
237
|
}
|
|
230
|
-
async function op<T>(operation: Omit<AndroidStepDetails, "device">, fn: () => Promise<T
|
|
238
|
+
async function op<T>(operation: Omit<AndroidStepDetails, "device">, fn: (details: AndroidStepDetails) => Promise<T>, settled?: (seq: number | undefined) => void): Promise<Wrapped<T>> {
|
|
231
239
|
assertActive();
|
|
232
240
|
const android = { device: app.name, ...operation };
|
|
233
241
|
const title = androidStepTitle(android);
|
|
234
242
|
const reservation = reserveEvent(),
|
|
235
243
|
started = Date.now();
|
|
236
244
|
try {
|
|
237
|
-
const result = await fn();
|
|
245
|
+
const result = await fn(android);
|
|
238
246
|
const seq = recordStep(
|
|
239
247
|
{
|
|
240
248
|
kind: "android",
|
|
@@ -245,9 +253,10 @@ export function androidScope(
|
|
|
245
253
|
},
|
|
246
254
|
reservation,
|
|
247
255
|
);
|
|
256
|
+
settled?.(seq);
|
|
248
257
|
return wrap(result, seq) as Wrapped<T>;
|
|
249
258
|
} catch (error) {
|
|
250
|
-
recordStep(
|
|
259
|
+
const seq = recordStep(
|
|
251
260
|
{
|
|
252
261
|
kind: "android",
|
|
253
262
|
title,
|
|
@@ -258,9 +267,35 @@ export function androidScope(
|
|
|
258
267
|
},
|
|
259
268
|
reservation,
|
|
260
269
|
);
|
|
270
|
+
settled?.(seq);
|
|
261
271
|
throw error;
|
|
262
272
|
}
|
|
263
273
|
}
|
|
274
|
+
async function bounds(path: string, details: AndroidStepDetails) {
|
|
275
|
+
delete details.bounds;
|
|
276
|
+
if (!captures.has(app.name)) return;
|
|
277
|
+
try {
|
|
278
|
+
if (!await command<boolean>("GET", `${path}/displayed`)) return;
|
|
279
|
+
const screen = await command<{ width: number; height: number }>("GET", "/window/rect");
|
|
280
|
+
const rect = await command<{ x: number; y: number; width: number; height: number }>("GET", `${path}/rect`);
|
|
281
|
+
const box = { x: rect.x / screen.width, y: rect.y / screen.height, width: rect.width / screen.width, height: rect.height / screen.height };
|
|
282
|
+
if (Object.values(box).every(Number.isFinite) && box.width > 0 && box.height > 0) details.bounds = box;
|
|
283
|
+
} catch { /* Replay decoration must not change the operation's result. */ }
|
|
284
|
+
}
|
|
285
|
+
const transient = (error: unknown) => error instanceof AppiumError && ["no such element", "stale element reference"].includes(error.code);
|
|
286
|
+
async function until<T>(probe: () => Promise<T | undefined>, label: string, timeoutMs = 10000): Promise<T> {
|
|
287
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs < 0) throw new Error("Invalid wait timeout");
|
|
288
|
+
const deadline = Date.now() + timeoutMs;
|
|
289
|
+
while (true) {
|
|
290
|
+
assertActive();
|
|
291
|
+
try {
|
|
292
|
+
const value = await probe();
|
|
293
|
+
if (value !== undefined) return value;
|
|
294
|
+
} catch (error) { if (!transient(error)) throw error; }
|
|
295
|
+
if (Date.now() >= deadline) throw new Error(`Timed out waiting for ${label}`);
|
|
296
|
+
await new Promise(resolve => setTimeout(resolve, Math.min(100, deadline - Date.now())));
|
|
297
|
+
}
|
|
298
|
+
}
|
|
264
299
|
const element = (using: string, value: string, target: AndroidTarget = androidTarget(using, value)): AndroidElement => {
|
|
265
300
|
const label = androidTargetLabel(target);
|
|
266
301
|
const find = async () => {
|
|
@@ -272,49 +307,88 @@ export function androidScope(
|
|
|
272
307
|
if (!id) throw new Error("Appium returned no element id");
|
|
273
308
|
return `/element/${encodeURIComponent(id)}`;
|
|
274
309
|
};
|
|
275
|
-
const
|
|
310
|
+
const visiblePath = async () => {
|
|
276
311
|
try {
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
error instanceof AppiumError &&
|
|
281
|
-
["no such element", "stale element reference"].includes(error.code)
|
|
282
|
-
)
|
|
283
|
-
return false;
|
|
284
|
-
throw error;
|
|
285
|
-
}
|
|
312
|
+
const path = await find();
|
|
313
|
+
return await command<boolean>("GET", `${path}/displayed`) ? path : undefined;
|
|
314
|
+
} catch (error) { if (transient(error)) return undefined; throw error; }
|
|
286
315
|
};
|
|
316
|
+
const ready = (timeoutMs?: number) => until(async () => {
|
|
317
|
+
const path = await visiblePath();
|
|
318
|
+
return path && await command<boolean>("GET", `${path}/enabled`) ? path : undefined;
|
|
319
|
+
}, `${label} to be visible and enabled`, timeoutMs);
|
|
287
320
|
return {
|
|
288
|
-
async tap() {
|
|
321
|
+
async tap(options = {}) {
|
|
289
322
|
await op({ action: "tap", target }, async () => {
|
|
290
|
-
const path = await
|
|
323
|
+
const path = await ready(options.timeoutMs);
|
|
291
324
|
const marker = await tapMarker(path);
|
|
325
|
+
// Never retry an input dispatch: a transport failure may occur
|
|
326
|
+
// after the app has received it.
|
|
292
327
|
await command("POST", `${path}/click`, {});
|
|
293
328
|
saveTap(marker);
|
|
294
329
|
});
|
|
295
330
|
},
|
|
296
|
-
async fill(text) {
|
|
297
|
-
await op({ action: "fill", target }, async () => {
|
|
298
|
-
const path = await
|
|
331
|
+
async fill(text, options = {}) {
|
|
332
|
+
await op({ action: "fill", target }, async (details) => {
|
|
333
|
+
const path = await ready(options.timeoutMs);
|
|
299
334
|
await command("POST", `${path}/clear`, {});
|
|
300
335
|
await command("POST", `${path}/value`, { text });
|
|
336
|
+
await bounds(path, details);
|
|
301
337
|
});
|
|
302
338
|
},
|
|
303
|
-
text: () =>
|
|
304
|
-
|
|
305
|
-
|
|
339
|
+
text: (options = {}) => op({ action: "text", target }, async (details) => {
|
|
340
|
+
return until(async () => {
|
|
341
|
+
const path = await find();
|
|
342
|
+
const value = await command<string>("GET", `${path}/text`);
|
|
343
|
+
await bounds(path, details);
|
|
344
|
+
return value;
|
|
345
|
+
}, label, options.timeoutMs);
|
|
346
|
+
}),
|
|
347
|
+
isDisplayed: () => op({ action: "visible", target }, async (details) => {
|
|
348
|
+
const path = await visiblePath();
|
|
349
|
+
if (path) await bounds(path, details);
|
|
350
|
+
return !!path;
|
|
351
|
+
}),
|
|
306
352
|
async waitFor(options = {}) {
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
} while (Date.now() < end);
|
|
315
|
-
throw new Error(`Timed out waiting for ${label}`);
|
|
353
|
+
await op({ action: "wait", target, state: options.state ?? "visible" }, async (details) => {
|
|
354
|
+
await until(async () => {
|
|
355
|
+
const path = await visiblePath();
|
|
356
|
+
if (path) await bounds(path, details);
|
|
357
|
+
else delete details.bounds;
|
|
358
|
+
return (!!path === (options.state !== "hidden")) ? true : undefined;
|
|
359
|
+
}, `${label} to be ${options.state ?? "visible"}`, options.timeoutMs);
|
|
316
360
|
});
|
|
317
361
|
},
|
|
362
|
+
async [ANDROID_ASSERT](matcher, negated, expected, timeoutMs) {
|
|
363
|
+
let sourceSeq: number | undefined, actual: unknown, error: unknown;
|
|
364
|
+
const visibility = matcher === "toBeVisible" || matcher === "toBeHidden";
|
|
365
|
+
try {
|
|
366
|
+
await op({ action: visibility ? "wait" : "text", target,
|
|
367
|
+
...(visibility ? { state: ((matcher === "toBeVisible") !== negated ? "visible" : "hidden") as "visible" | "hidden" } : {}) }, async (details) => {
|
|
368
|
+
await until(async () => {
|
|
369
|
+
let path: string | undefined;
|
|
370
|
+
let matches: boolean;
|
|
371
|
+
if (visibility) {
|
|
372
|
+
path = await visiblePath();
|
|
373
|
+
actual = !!path;
|
|
374
|
+
matches = matcher === "toBeVisible" ? !!path : !path;
|
|
375
|
+
} else {
|
|
376
|
+
path = await find();
|
|
377
|
+
const text = await command<string>("GET", `${path}/text`);
|
|
378
|
+
actual = text;
|
|
379
|
+
const normalize = (value: string) => value.replace(/\s+/g, " ").trim();
|
|
380
|
+
matches = expected instanceof RegExp
|
|
381
|
+
? new RegExp(expected.source, expected.flags).test(text)
|
|
382
|
+
: matcher === "toContainText" ? normalize(text).includes(normalize(expected!)) : normalize(text) === normalize(expected!);
|
|
383
|
+
}
|
|
384
|
+
if (path) await bounds(path, details);
|
|
385
|
+
else delete details.bounds;
|
|
386
|
+
return matches !== negated ? true : undefined;
|
|
387
|
+
}, `${label} ${negated ? "not " : ""}${matcher}${expected === undefined ? "" : ` ${expected}`}`, timeoutMs);
|
|
388
|
+
}, seq => { sourceSeq = seq; });
|
|
389
|
+
} catch (cause) { error = cause; }
|
|
390
|
+
return { sourceSeq, actual, error };
|
|
391
|
+
},
|
|
318
392
|
};
|
|
319
393
|
};
|
|
320
394
|
return {
|
|
@@ -32,6 +32,7 @@ test("Android expands into two VM services with an APK input and private Appium/
|
|
|
32
32
|
const env = JSON.parse(output);
|
|
33
33
|
const device = env.services["phone-device"],
|
|
34
34
|
tools = env.services.phone;
|
|
35
|
+
expect(device.image.reference).toBe("redroid/redroid:16.0.0_64only-latest@sha256:7b1e389bd15f37af3bcd06138f5b2ffa7cfba4332bd5ef54c53e99c2f160a15b");
|
|
35
36
|
expect(device.privileged).toBe(true);
|
|
36
37
|
expect(device.args).toContain("androidboot.redroid_width=1080");
|
|
37
38
|
expect(device.args).toContain("androidboot.redroid_height=2400");
|
|
@@ -12,7 +12,7 @@ export interface AndroidOptions {
|
|
|
12
12
|
package: string;
|
|
13
13
|
/** Launch activity, for example .MainActivity. */
|
|
14
14
|
activity: string;
|
|
15
|
-
/** Android image; default is the
|
|
15
|
+
/** Android image; default is the pinned Android 16 64-bit-only redroid image. */
|
|
16
16
|
image?: string;
|
|
17
17
|
/** Optional prebuilt image containing the same tools as ANDROID_TOOLS_DOCKERFILE. */
|
|
18
18
|
toolsImage?: string;
|
|
@@ -78,7 +78,7 @@ export function android(options: AndroidOptions) {
|
|
|
78
78
|
type: "registry" as const,
|
|
79
79
|
reference:
|
|
80
80
|
options.image ??
|
|
81
|
-
"redroid/redroid:
|
|
81
|
+
"redroid/redroid:16.0.0_64only-latest@sha256:7b1e389bd15f37af3bcd06138f5b2ffa7cfba4332bd5ef54c53e99c2f160a15b",
|
|
82
82
|
},
|
|
83
83
|
privileged: true,
|
|
84
84
|
// Android's netd ignores resolv.conf. Use Docker's per-container DNS,
|