lynkeus 0.2.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/lib/__tests__/args.test.d.ts +1 -0
- package/lib/__tests__/args.test.js +69 -0
- package/lib/__tests__/check.test.d.ts +1 -0
- package/lib/__tests__/check.test.js +60 -0
- package/lib/__tests__/init.test.d.ts +1 -0
- package/lib/__tests__/init.test.js +42 -0
- package/lib/__tests__/record.test.d.ts +1 -0
- package/lib/__tests__/record.test.js +44 -0
- package/lib/__tests__/snapshot.test.d.ts +1 -0
- package/lib/__tests__/snapshot.test.js +47 -0
- package/lib/__tests__/targets.test.d.ts +1 -0
- package/lib/__tests__/targets.test.js +127 -0
- package/lib/args.d.ts +30 -0
- package/lib/args.js +86 -0
- package/lib/cases/__tests__/format.test.d.ts +1 -0
- package/lib/cases/__tests__/format.test.js +61 -0
- package/lib/cases/__tests__/runner.test.d.ts +1 -0
- package/lib/cases/__tests__/runner.test.js +188 -0
- package/lib/cases/fixtures.d.ts +13 -0
- package/lib/cases/fixtures.js +100 -0
- package/lib/cases/format.d.ts +37 -0
- package/lib/cases/format.js +169 -0
- package/lib/cases/runner.d.ts +61 -0
- package/lib/cases/runner.js +590 -0
- package/lib/cases/templates.d.ts +20 -0
- package/lib/cases/templates.js +156 -0
- package/lib/cli.d.ts +6 -0
- package/lib/cli.js +173 -0
- package/lib/commands/cases.d.ts +1 -0
- package/lib/commands/cases.js +179 -0
- package/lib/commands/check.d.ts +36 -0
- package/lib/commands/check.js +67 -0
- package/lib/commands/crawl/return.d.ts +20 -0
- package/lib/commands/crawl/return.js +125 -0
- package/lib/commands/crawl/state.d.ts +30 -0
- package/lib/commands/crawl/state.js +56 -0
- package/lib/commands/crawl.d.ts +67 -0
- package/lib/commands/crawl.js +213 -0
- package/lib/commands/device.d.ts +10 -0
- package/lib/commands/device.js +122 -0
- package/lib/commands/doctor.d.ts +3 -0
- package/lib/commands/doctor.js +240 -0
- package/lib/commands/drive.d.ts +1 -0
- package/lib/commands/drive.js +410 -0
- package/lib/commands/flake.d.ts +14 -0
- package/lib/commands/flake.js +42 -0
- package/lib/commands/headless.d.ts +1 -0
- package/lib/commands/headless.js +127 -0
- package/lib/commands/init.d.ts +13 -0
- package/lib/commands/init.js +85 -0
- package/lib/commands/map.d.ts +2 -0
- package/lib/commands/map.js +297 -0
- package/lib/commands/os.d.ts +1 -0
- package/lib/commands/os.js +193 -0
- package/lib/commands/overlay.d.ts +9 -0
- package/lib/commands/overlay.js +12 -0
- package/lib/commands/record.d.ts +5 -0
- package/lib/commands/record.js +55 -0
- package/lib/commands/runs.d.ts +1 -0
- package/lib/commands/runs.js +452 -0
- package/lib/commands/smoke.d.ts +28 -0
- package/lib/commands/smoke.js +59 -0
- package/lib/commands/snapshot.d.ts +27 -0
- package/lib/commands/snapshot.js +69 -0
- package/lib/config.d.ts +72 -0
- package/lib/config.js +44 -0
- package/lib/device/devices.d.ts +11 -0
- package/lib/device/devices.js +33 -0
- package/lib/device/driver.d.ts +53 -0
- package/lib/device/driver.js +169 -0
- package/lib/device/platform.d.ts +7 -0
- package/lib/device/platform.js +59 -0
- package/lib/files.d.ts +7 -0
- package/lib/files.js +29 -0
- package/lib/flow/artifacts.d.ts +19 -0
- package/lib/flow/artifacts.js +49 -0
- package/lib/flow/runner.d.ts +13 -0
- package/lib/flow/runner.js +169 -0
- package/lib/flow/types.d.ts +111 -0
- package/lib/flow/types.js +1 -0
- package/lib/index.d.ts +18 -0
- package/lib/index.js +11 -0
- package/lib/knowledge/__tests__/edges.test.d.ts +1 -0
- package/lib/knowledge/__tests__/edges.test.js +25 -0
- package/lib/knowledge/__tests__/events.test.d.ts +1 -0
- package/lib/knowledge/__tests__/events.test.js +39 -0
- package/lib/knowledge/__tests__/extract.test.d.ts +1 -0
- package/lib/knowledge/__tests__/extract.test.js +69 -0
- package/lib/knowledge/__tests__/frontier.test.d.ts +1 -0
- package/lib/knowledge/__tests__/frontier.test.js +57 -0
- package/lib/knowledge/__tests__/funnels.test.d.ts +1 -0
- package/lib/knowledge/__tests__/funnels.test.js +40 -0
- package/lib/knowledge/__tests__/lint.test.d.ts +1 -0
- package/lib/knowledge/__tests__/lint.test.js +53 -0
- package/lib/knowledge/__tests__/perf.test.d.ts +1 -0
- package/lib/knowledge/__tests__/perf.test.js +34 -0
- package/lib/knowledge/__tests__/plan.test.d.ts +1 -0
- package/lib/knowledge/__tests__/plan.test.js +35 -0
- package/lib/knowledge/__tests__/query.test.d.ts +1 -0
- package/lib/knowledge/__tests__/query.test.js +55 -0
- package/lib/knowledge/__tests__/screens.test.d.ts +1 -0
- package/lib/knowledge/__tests__/screens.test.js +30 -0
- package/lib/knowledge/__tests__/why.test.d.ts +1 -0
- package/lib/knowledge/__tests__/why.test.js +28 -0
- package/lib/knowledge/edges.d.ts +30 -0
- package/lib/knowledge/edges.js +68 -0
- package/lib/knowledge/errors.d.ts +8 -0
- package/lib/knowledge/errors.js +62 -0
- package/lib/knowledge/events.d.ts +23 -0
- package/lib/knowledge/events.js +51 -0
- package/lib/knowledge/extract.d.ts +26 -0
- package/lib/knowledge/extract.js +365 -0
- package/lib/knowledge/frontier.d.ts +26 -0
- package/lib/knowledge/frontier.js +60 -0
- package/lib/knowledge/funnels.d.ts +46 -0
- package/lib/knowledge/funnels.js +103 -0
- package/lib/knowledge/lint.d.ts +16 -0
- package/lib/knowledge/lint.js +89 -0
- package/lib/knowledge/perf.d.ts +32 -0
- package/lib/knowledge/perf.js +91 -0
- package/lib/knowledge/plan.d.ts +22 -0
- package/lib/knowledge/plan.js +81 -0
- package/lib/knowledge/problems.d.ts +5 -0
- package/lib/knowledge/problems.js +8 -0
- package/lib/knowledge/query.d.ts +36 -0
- package/lib/knowledge/query.js +127 -0
- package/lib/knowledge/route-params.d.ts +6 -0
- package/lib/knowledge/route-params.js +42 -0
- package/lib/knowledge/screens.d.ts +30 -0
- package/lib/knowledge/screens.js +125 -0
- package/lib/knowledge/tree.d.ts +15 -0
- package/lib/knowledge/tree.js +63 -0
- package/lib/knowledge/types.d.ts +62 -0
- package/lib/knowledge/types.js +6 -0
- package/lib/knowledge/why.d.ts +24 -0
- package/lib/knowledge/why.js +153 -0
- package/lib/mcp/server.d.ts +4 -0
- package/lib/mcp/server.js +111 -0
- package/lib/registry.d.ts +78 -0
- package/lib/registry.js +113 -0
- package/lib/runtime.d.ts +12 -0
- package/lib/runtime.js +56 -0
- package/lib/targets.d.ts +17 -0
- package/lib/targets.js +43 -0
- package/package.json +47 -0
- package/src/__tests__/args.test.ts +82 -0
- package/src/__tests__/check.test.ts +71 -0
- package/src/__tests__/init.test.ts +52 -0
- package/src/__tests__/record.test.ts +49 -0
- package/src/__tests__/snapshot.test.ts +51 -0
- package/src/__tests__/targets.test.ts +132 -0
- package/src/args.ts +111 -0
- package/src/cases/__tests__/format.test.ts +76 -0
- package/src/cases/__tests__/runner.test.ts +230 -0
- package/src/cases/fixtures.ts +104 -0
- package/src/cases/format.ts +183 -0
- package/src/cases/runner.ts +575 -0
- package/src/cases/templates.ts +159 -0
- package/src/cli.ts +176 -0
- package/src/commands/cases.ts +170 -0
- package/src/commands/check.ts +83 -0
- package/src/commands/crawl/return.ts +130 -0
- package/src/commands/crawl/state.ts +70 -0
- package/src/commands/crawl.ts +262 -0
- package/src/commands/device.ts +119 -0
- package/src/commands/doctor.ts +237 -0
- package/src/commands/drive.ts +413 -0
- package/src/commands/flake.ts +48 -0
- package/src/commands/headless.ts +131 -0
- package/src/commands/init.ts +98 -0
- package/src/commands/map.ts +298 -0
- package/src/commands/os.ts +195 -0
- package/src/commands/overlay.ts +20 -0
- package/src/commands/record.ts +46 -0
- package/src/commands/runs.ts +475 -0
- package/src/commands/smoke.ts +86 -0
- package/src/commands/snapshot.ts +87 -0
- package/src/config.ts +103 -0
- package/src/device/devices.ts +44 -0
- package/src/device/driver.ts +197 -0
- package/src/device/platform.ts +74 -0
- package/src/files.ts +35 -0
- package/src/flow/artifacts.ts +61 -0
- package/src/flow/runner.ts +182 -0
- package/src/flow/types.ts +55 -0
- package/src/index.ts +18 -0
- package/src/knowledge/__tests__/edges.test.ts +34 -0
- package/src/knowledge/__tests__/events.test.ts +45 -0
- package/src/knowledge/__tests__/extract.test.ts +88 -0
- package/src/knowledge/__tests__/frontier.test.ts +77 -0
- package/src/knowledge/__tests__/funnels.test.ts +58 -0
- package/src/knowledge/__tests__/lint.test.ts +66 -0
- package/src/knowledge/__tests__/perf.test.ts +41 -0
- package/src/knowledge/__tests__/plan.test.ts +52 -0
- package/src/knowledge/__tests__/query.test.ts +74 -0
- package/src/knowledge/__tests__/screens.test.ts +37 -0
- package/src/knowledge/__tests__/why.test.ts +40 -0
- package/src/knowledge/edges.ts +91 -0
- package/src/knowledge/errors.ts +73 -0
- package/src/knowledge/events.ts +60 -0
- package/src/knowledge/extract.ts +379 -0
- package/src/knowledge/frontier.ts +82 -0
- package/src/knowledge/funnels.ts +126 -0
- package/src/knowledge/lint.ts +99 -0
- package/src/knowledge/perf.ts +114 -0
- package/src/knowledge/plan.ts +95 -0
- package/src/knowledge/problems.ts +12 -0
- package/src/knowledge/query.ts +146 -0
- package/src/knowledge/route-params.ts +39 -0
- package/src/knowledge/screens.ts +139 -0
- package/src/knowledge/tree.ts +66 -0
- package/src/knowledge/types.ts +60 -0
- package/src/knowledge/why.ts +189 -0
- package/src/mcp/server.ts +109 -0
- package/src/registry.ts +191 -0
- package/src/runtime.ts +54 -0
- package/src/targets.ts +44 -0
package/lib/config.d.ts
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { CasesConfig } from './cases/runner.js';
|
|
2
|
+
import type { ScreenGraph } from './knowledge/types.js';
|
|
3
|
+
export declare const lynkeusDir: (root?: string) => string;
|
|
4
|
+
export declare const graphFile: (root?: string) => string;
|
|
5
|
+
export declare const edgesFile: (root?: string) => string;
|
|
6
|
+
export declare const frontierFile: (root?: string) => string;
|
|
7
|
+
export declare const runsDir: (root?: string) => string;
|
|
8
|
+
export declare const loadGraph: (root?: string) => ScreenGraph | null;
|
|
9
|
+
/**
|
|
10
|
+
* `lynkeus.config.json` in the app's folder: what would otherwise be repeated
|
|
11
|
+
* as flags or environment on every invocation. Flags win over the
|
|
12
|
+
* environment, the environment over the file.
|
|
13
|
+
*/
|
|
14
|
+
export type LynkeusConfig = {
|
|
15
|
+
cases?: CasesConfig;
|
|
16
|
+
/** The headless entry (default `qa/headless.tsx`). */
|
|
17
|
+
entry?: string;
|
|
18
|
+
/** The environment variable the app reads its API base from; the headless entry refuses one that is not local. */
|
|
19
|
+
apiEnv?: string;
|
|
20
|
+
/** The driver port the app dials. */
|
|
21
|
+
port?: number;
|
|
22
|
+
/** Simulator udid or name. */
|
|
23
|
+
device?: string;
|
|
24
|
+
/** Bundle id or application id. */
|
|
25
|
+
app?: string;
|
|
26
|
+
/**
|
|
27
|
+
* The language this app speaks, passed on and never interpreted: `os
|
|
28
|
+
* language` defaults to it, the headless entry mocks this locale, and an MCP
|
|
29
|
+
* client is told, so a model reading the screens knows their language.
|
|
30
|
+
*/
|
|
31
|
+
language?: string;
|
|
32
|
+
locale?: {
|
|
33
|
+
languageTag?: string;
|
|
34
|
+
country?: string;
|
|
35
|
+
};
|
|
36
|
+
/** How long to wait for the app to attach, in ms. */
|
|
37
|
+
attachTimeout?: number;
|
|
38
|
+
/** The lint baseline `lint` compares against when no file is given. */
|
|
39
|
+
lintBaseline?: string;
|
|
40
|
+
/** Regular expressions for the text this app shows when it is broken; `smoke` and `crawl` report a match. */
|
|
41
|
+
errorCopy?: string[];
|
|
42
|
+
/** The shape of an untranslated key, and the smallest acceptable touch target in points. */
|
|
43
|
+
lint?: {
|
|
44
|
+
i18nKeyPatterns?: string[];
|
|
45
|
+
minTouchTarget?: number;
|
|
46
|
+
};
|
|
47
|
+
/**
|
|
48
|
+
* Regular expressions for the testIDs of controls whose content changes on
|
|
49
|
+
* its own (a ticking clock, a countdown). Their text stops counting as a
|
|
50
|
+
* commit, or `idle` would wait out its whole timeout on every screen that
|
|
51
|
+
* shows one.
|
|
52
|
+
*/
|
|
53
|
+
live?: string[];
|
|
54
|
+
/** Keep React.StrictMode in the headless host. Off by default: its double renders inflate what the host measures. */
|
|
55
|
+
strictMode?: boolean;
|
|
56
|
+
/** Regular expressions matched against a control's testID and label. lynkeus ships none: only the app knows which controls are irreversible. */
|
|
57
|
+
crawl?: {
|
|
58
|
+
/** Never pressed. */
|
|
59
|
+
never?: string[];
|
|
60
|
+
/** Close an overlay the app raises. */
|
|
61
|
+
close?: string[];
|
|
62
|
+
/** Go back. Without them the crawl takes a small control in the top-left corner for one. */
|
|
63
|
+
back?: string[];
|
|
64
|
+
};
|
|
65
|
+
};
|
|
66
|
+
export declare const configFile: (root?: string) => string;
|
|
67
|
+
/** The app's configuration, under the one `LYNKEUS_CONFIG` names: cases and fixtures often live in a repository of their own. */
|
|
68
|
+
export declare const loadConfig: (root?: string) => LynkeusConfig;
|
|
69
|
+
export declare const portFromEnv: (config?: LynkeusConfig) => number | undefined;
|
|
70
|
+
/** A list of regular expressions from the configuration, as one that matches any of them. */
|
|
71
|
+
export declare const anyOf: (patterns: string[] | undefined) => RegExp | undefined;
|
|
72
|
+
export declare const packageName: (root: string) => string;
|
package/lib/config.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { readJson } from './files.js';
|
|
4
|
+
export const lynkeusDir = (root = process.cwd()) => path.join(root, '.lynkeus');
|
|
5
|
+
export const graphFile = (root) => path.join(lynkeusDir(root), 'graph.json');
|
|
6
|
+
export const edgesFile = (root) => path.join(lynkeusDir(root), 'edges.jsonl');
|
|
7
|
+
export const frontierFile = (root) => path.join(lynkeusDir(root), 'frontier.jsonl');
|
|
8
|
+
export const runsDir = (root) => path.join(lynkeusDir(root), 'runs');
|
|
9
|
+
export const loadGraph = (root) => {
|
|
10
|
+
const file = graphFile(root);
|
|
11
|
+
return fs.existsSync(file) ? readJson(file) : null;
|
|
12
|
+
};
|
|
13
|
+
export const configFile = (root) => path.join(root ?? process.cwd(), 'lynkeus.config.json');
|
|
14
|
+
const readConfig = (file) => {
|
|
15
|
+
if (!fs.existsSync(file))
|
|
16
|
+
return {};
|
|
17
|
+
try {
|
|
18
|
+
const config = readJson(file);
|
|
19
|
+
// Paths under `cases` are relative to the file that declares them.
|
|
20
|
+
if (config.cases)
|
|
21
|
+
config.cases.base = path.dirname(path.resolve(file));
|
|
22
|
+
return config;
|
|
23
|
+
}
|
|
24
|
+
catch (error) {
|
|
25
|
+
throw new Error(`${file}: ${error instanceof Error ? error.message : String(error)}`);
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
/** The app's configuration, under the one `LYNKEUS_CONFIG` names: cases and fixtures often live in a repository of their own. */
|
|
29
|
+
export const loadConfig = (root) => {
|
|
30
|
+
const own = readConfig(configFile(root));
|
|
31
|
+
const extra = process.env.LYNKEUS_CONFIG ? readConfig(path.resolve(process.env.LYNKEUS_CONFIG)) : {};
|
|
32
|
+
return { ...own, ...extra };
|
|
33
|
+
};
|
|
34
|
+
export const portFromEnv = (config = {}) => (process.env.LYNKEUS_PORT ? Number(process.env.LYNKEUS_PORT) : config.port);
|
|
35
|
+
/** A list of regular expressions from the configuration, as one that matches any of them. */
|
|
36
|
+
export const anyOf = (patterns) => (patterns?.length ? new RegExp(patterns.join('|'), 'i') : undefined);
|
|
37
|
+
export const packageName = (root) => {
|
|
38
|
+
try {
|
|
39
|
+
return JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')).name ?? path.basename(root);
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return path.basename(root);
|
|
43
|
+
}
|
|
44
|
+
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export type Platform = 'ios' | 'android';
|
|
2
|
+
export type DeviceInfo = {
|
|
3
|
+
id: string;
|
|
4
|
+
name: string;
|
|
5
|
+
platform: Platform;
|
|
6
|
+
type: 'simulator' | 'emulator' | 'physical';
|
|
7
|
+
connected: boolean;
|
|
8
|
+
};
|
|
9
|
+
export declare const bootedSimulators: () => Promise<DeviceInfo[]>;
|
|
10
|
+
export declare const HEADLESS_HOST: DeviceInfo;
|
|
11
|
+
export declare const pickAgentDevice: (wanted?: string) => Promise<DeviceInfo>;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { promisify } from 'node:util';
|
|
3
|
+
const run = promisify(execFile);
|
|
4
|
+
export const bootedSimulators = async () => {
|
|
5
|
+
// No Xcode (Linux, CI) means no simulators, not a crash: the app may be hosted headless.
|
|
6
|
+
let stdout;
|
|
7
|
+
try {
|
|
8
|
+
({ stdout } = await run('xcrun', ['simctl', 'list', 'devices', 'booted', '-j']));
|
|
9
|
+
}
|
|
10
|
+
catch (error) {
|
|
11
|
+
if (error.code === 'ENOENT')
|
|
12
|
+
return [];
|
|
13
|
+
throw error;
|
|
14
|
+
}
|
|
15
|
+
const parsed = JSON.parse(stdout);
|
|
16
|
+
return Object.values(parsed.devices)
|
|
17
|
+
.flat()
|
|
18
|
+
.filter((d) => d.state === 'Booted')
|
|
19
|
+
.map((d) => ({ id: d.udid, name: d.name, platform: 'ios', type: 'simulator', connected: true }));
|
|
20
|
+
};
|
|
21
|
+
export const HEADLESS_HOST = { id: 'headless', name: 'headless host', platform: 'ios', type: 'simulator', connected: true };
|
|
22
|
+
export const pickAgentDevice = async (wanted) => {
|
|
23
|
+
const devices = await bootedSimulators();
|
|
24
|
+
const id = wanted ?? process.env.LYNKEUS_DEVICE;
|
|
25
|
+
const device = id ? devices.find((d) => d.id === id || d.name.includes(id)) : devices[0];
|
|
26
|
+
if (!device) {
|
|
27
|
+
// A headless host dials the driver itself: nothing to boot or attach to.
|
|
28
|
+
if (!id && devices.length === 0)
|
|
29
|
+
return HEADLESS_HOST;
|
|
30
|
+
throw new Error(id ? `No booted simulator matching "${id}".` : 'No booted simulator. Boot one first.');
|
|
31
|
+
}
|
|
32
|
+
return device;
|
|
33
|
+
};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An app running the agent. The screen is the React tree the app hands over and
|
|
3
|
+
* touches are synthesized inside its process, so an action costs milliseconds;
|
|
4
|
+
* only launching, screenshots and the status bar go through simctl or adb.
|
|
5
|
+
*/
|
|
6
|
+
import { AgentServer } from 'lynkeus-client';
|
|
7
|
+
import type { CoreMethods, Screen, TraceEvent } from 'lynkeus-protocol';
|
|
8
|
+
import type { DeviceInfo } from './devices.js';
|
|
9
|
+
export declare class Device {
|
|
10
|
+
#private;
|
|
11
|
+
info: DeviceInfo;
|
|
12
|
+
readonly server: AgentServer;
|
|
13
|
+
constructor(info: DeviceInfo, options?: {
|
|
14
|
+
server?: AgentServer;
|
|
15
|
+
appId?: string | null;
|
|
16
|
+
resolving?: Promise<DeviceInfo>;
|
|
17
|
+
});
|
|
18
|
+
/** The device as the OS knows it: reading the screen needs none of it; launching, screenshots and OS commands do. */
|
|
19
|
+
resolved(): Promise<DeviceInfo>;
|
|
20
|
+
get appId(): string | null;
|
|
21
|
+
stop(): Promise<void>;
|
|
22
|
+
launchApp(appId: string, options?: {
|
|
23
|
+
fresh?: boolean;
|
|
24
|
+
}): Promise<void>;
|
|
25
|
+
openUrl(url: string): Promise<void>;
|
|
26
|
+
/** Pins the clock, battery and radios so a pixel baseline survives the calendar. */
|
|
27
|
+
freezeChrome(): Promise<void>;
|
|
28
|
+
screenshot(destination: string): Promise<void>;
|
|
29
|
+
screen(): Promise<Screen>;
|
|
30
|
+
press(params: CoreMethods['press']['params']): Promise<CoreMethods['press']['result']>;
|
|
31
|
+
type(params: CoreMethods['type']['params']): Promise<CoreMethods['type']['result']>;
|
|
32
|
+
swipe(params: CoreMethods['swipe']['params']): Promise<CoreMethods['swipe']['result']>;
|
|
33
|
+
back(): Promise<CoreMethods['back']['result']>;
|
|
34
|
+
navigate(name: string, params?: object): Promise<void>;
|
|
35
|
+
waitFor(params: CoreMethods['waitFor']['params']): Promise<CoreMethods['waitFor']['result']>;
|
|
36
|
+
idle(params?: CoreMethods['idle']['params']): Promise<CoreMethods['idle']['result']>;
|
|
37
|
+
requests(since?: number): Promise<CoreMethods['requests']['result']>;
|
|
38
|
+
trace(since?: number): Promise<CoreMethods['trace']['result']>;
|
|
39
|
+
/** A command the app registered with `qa.register`. */
|
|
40
|
+
command<T = unknown>(name: string, params?: unknown): Promise<T>;
|
|
41
|
+
}
|
|
42
|
+
export declare const describeTrace: (events: TraceEvent[]) => string;
|
|
43
|
+
type DescribeOptions = {
|
|
44
|
+
limit?: number;
|
|
45
|
+
/** Also what the platform is not presenting: behind a layer, out of the accessibility tree. */
|
|
46
|
+
all?: boolean;
|
|
47
|
+
/** Each element's centre, in points. */
|
|
48
|
+
frames?: boolean;
|
|
49
|
+
/** The commit count and the read's cost in the header. */
|
|
50
|
+
verbose?: boolean;
|
|
51
|
+
};
|
|
52
|
+
export declare const describeScreen: (screen: Screen, options?: DescribeOptions) => string;
|
|
53
|
+
export {};
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An app running the agent. The screen is the React tree the app hands over and
|
|
3
|
+
* touches are synthesized inside its process, so an action costs milliseconds;
|
|
4
|
+
* only launching, screenshots and the status bar go through simctl or adb.
|
|
5
|
+
*/
|
|
6
|
+
import { AgentServer } from 'lynkeus-client';
|
|
7
|
+
import { portFromEnv } from '../config.js';
|
|
8
|
+
import { withoutHost } from '../knowledge/problems.js';
|
|
9
|
+
import { lifecycleFor } from './platform.js';
|
|
10
|
+
export class Device {
|
|
11
|
+
info;
|
|
12
|
+
server;
|
|
13
|
+
#lifecycle;
|
|
14
|
+
#appId;
|
|
15
|
+
#resolving;
|
|
16
|
+
constructor(info, options = {}) {
|
|
17
|
+
this.info = info;
|
|
18
|
+
this.#resolving = options.resolving;
|
|
19
|
+
// The app serves commands only to a driver that presents its token; without one, anything on the loopback could drive a dev build.
|
|
20
|
+
this.server = options.server ?? new AgentServer({ token: process.env.LYNKEUS_TOKEN, port: portFromEnv() });
|
|
21
|
+
this.#lifecycle = lifecycleFor(info.platform);
|
|
22
|
+
this.#appId = options.appId ?? null;
|
|
23
|
+
}
|
|
24
|
+
/** The device as the OS knows it: reading the screen needs none of it; launching, screenshots and OS commands do. */
|
|
25
|
+
async resolved() {
|
|
26
|
+
if (this.#resolving) {
|
|
27
|
+
this.info = await this.#resolving;
|
|
28
|
+
this.#resolving = undefined;
|
|
29
|
+
}
|
|
30
|
+
return this.info;
|
|
31
|
+
}
|
|
32
|
+
get appId() {
|
|
33
|
+
return this.#appId;
|
|
34
|
+
}
|
|
35
|
+
async stop() {
|
|
36
|
+
await this.server.close();
|
|
37
|
+
}
|
|
38
|
+
async launchApp(appId, options = {}) {
|
|
39
|
+
this.#appId = appId;
|
|
40
|
+
await this.server.listen();
|
|
41
|
+
await this.#lifecycle.launchApp((await this.resolved()).id, appId, options.fresh ?? true);
|
|
42
|
+
await this.server.waitForApp();
|
|
43
|
+
}
|
|
44
|
+
async openUrl(url) {
|
|
45
|
+
await this.server.call('deepLink', { url });
|
|
46
|
+
}
|
|
47
|
+
/** Pins the clock, battery and radios so a pixel baseline survives the calendar. */
|
|
48
|
+
async freezeChrome() {
|
|
49
|
+
await this.#lifecycle.freezeChrome((await this.resolved()).id);
|
|
50
|
+
}
|
|
51
|
+
async screenshot(destination) {
|
|
52
|
+
await this.#lifecycle.screenshot((await this.resolved()).id, destination);
|
|
53
|
+
}
|
|
54
|
+
screen() {
|
|
55
|
+
return this.server.call('screen');
|
|
56
|
+
}
|
|
57
|
+
press(params) {
|
|
58
|
+
return this.server.call('press', params);
|
|
59
|
+
}
|
|
60
|
+
type(params) {
|
|
61
|
+
return this.server.call('type', params);
|
|
62
|
+
}
|
|
63
|
+
swipe(params) {
|
|
64
|
+
return this.server.call('swipe', params);
|
|
65
|
+
}
|
|
66
|
+
back() {
|
|
67
|
+
return this.server.call('back');
|
|
68
|
+
}
|
|
69
|
+
async navigate(name, params) {
|
|
70
|
+
await this.server.call('navigate', { name, params });
|
|
71
|
+
}
|
|
72
|
+
// The agent's own timeout governs; the transport waits a little longer so it hears the agent's answer.
|
|
73
|
+
waitFor(params) {
|
|
74
|
+
return this.server.call('waitFor', params, (params.timeoutMs ?? 5000) + 2000);
|
|
75
|
+
}
|
|
76
|
+
idle(params) {
|
|
77
|
+
return this.server.call('idle', params, (params?.timeoutMs ?? 3000) + 2000);
|
|
78
|
+
}
|
|
79
|
+
requests(since) {
|
|
80
|
+
return this.server.call('requests', { since });
|
|
81
|
+
}
|
|
82
|
+
trace(since) {
|
|
83
|
+
return this.server.call('trace', { since });
|
|
84
|
+
}
|
|
85
|
+
/** A command the app registered with `qa.register`. */
|
|
86
|
+
command(name, params) {
|
|
87
|
+
return this.server.call(name, params);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
const quote = (value) => JSON.stringify(value);
|
|
91
|
+
export const describeTrace = (events) => {
|
|
92
|
+
if (events.length === 0)
|
|
93
|
+
return '(no events)';
|
|
94
|
+
const t0 = events[0].t;
|
|
95
|
+
const rel = (t) => `+${String(t - t0).padStart(6)}ms`;
|
|
96
|
+
return events
|
|
97
|
+
.map((e) => {
|
|
98
|
+
if (e.kind === 'route')
|
|
99
|
+
return `${rel(e.t)} route ${e.route ?? '?'} (${e.path.join(' > ')})`;
|
|
100
|
+
if (e.kind === 'request')
|
|
101
|
+
return `${rel(e.t)} request ${String(e.status ?? '…').padStart(3)} ${e.method.padEnd(5)} ${withoutHost(e.url)}${e.error ? ` (${e.error})` : ''}`;
|
|
102
|
+
if (e.kind === 'stall')
|
|
103
|
+
return `${rel(e.t)} stall render loop broken: ${e.renders} updates${e.component ? ` to ${e.component}` : ''} in ${e.windowMs}ms`;
|
|
104
|
+
if (e.kind === 'touch')
|
|
105
|
+
return `${rel(e.t)} touch ${e.testId ? `#${e.testId}` : e.text ? JSON.stringify(e.text) : `${e.x},${e.y}`} on ${e.route ?? '?'}`;
|
|
106
|
+
if (e.kind === 'crash')
|
|
107
|
+
return `${rel(e.t)} crash ${e.message}`;
|
|
108
|
+
if (e.kind === 'event')
|
|
109
|
+
return `${rel(e.t)} event ${e.name}${e.props ? ` ${JSON.stringify(e.props)}` : ''}`;
|
|
110
|
+
if (e.kind === 'store')
|
|
111
|
+
return `${rel(e.t)} store ${e.store}: ${Object.entries(e.changed)
|
|
112
|
+
.map(([k, v]) => `${k} ${JSON.stringify(v.from)}→${JSON.stringify(v.to)}`)
|
|
113
|
+
.join(', ')}`;
|
|
114
|
+
return `${rel(e.t)} command ${e.method} ${e.ms}ms${e.error ? ` ✗ ${e.error}` : ''}`;
|
|
115
|
+
})
|
|
116
|
+
.join('\n');
|
|
117
|
+
};
|
|
118
|
+
export const describeScreen = (screen, options = {}) => {
|
|
119
|
+
const { limit = 80, all = false, frames = false, verbose = false } = options;
|
|
120
|
+
const layer = screen.presenting ? ` +${screen.presenting.modal ? 'modal' : 'overlay'}${screen.presenting.testId ? `#${screen.presenting.testId}` : ''}` : '';
|
|
121
|
+
// Plain views are layout; one that names itself (a testID, a label) is content.
|
|
122
|
+
const named = screen.elements.filter((e) => e.kind !== 'view' || e.accessibilityLabel !== undefined || e.testId !== undefined);
|
|
123
|
+
// What sits inside a button is the button: its icon is not something else to read or press.
|
|
124
|
+
const insideButton = (e) => {
|
|
125
|
+
for (let p = e.parent; p !== undefined; p = screen.elements[p]?.parent)
|
|
126
|
+
if (screen.elements[p]?.kind === 'button')
|
|
127
|
+
return true;
|
|
128
|
+
return false;
|
|
129
|
+
};
|
|
130
|
+
// An inert control is still read, even though it cannot be touched.
|
|
131
|
+
const listed = all ? named : named.filter((e) => (!e.hidden || e.hidden === 'inert') && !(e.kind === 'view' && insideButton(e)));
|
|
132
|
+
const counts = new Map();
|
|
133
|
+
if (!all)
|
|
134
|
+
for (const e of named)
|
|
135
|
+
if (e.hidden && e.hidden !== 'inert')
|
|
136
|
+
counts.set(e.hidden, (counts.get(e.hidden) ?? 0) + 1);
|
|
137
|
+
const hiddenTotal = [...counts.values()].reduce((sum, n) => sum + n, 0);
|
|
138
|
+
const hiddenNote = hiddenTotal ? ` · ${hiddenTotal} hidden: ${[...counts].map(([reason, n]) => `${n} ${reason}`).join(', ')}` : '';
|
|
139
|
+
const stats = verbose ? ` commit=${screen.commit}${screen.busy ? ' busy' : ''} ${screen.costMs}ms` : screen.busy ? ' busy' : '';
|
|
140
|
+
// The path says which navigators hold the route; alone, it only repeats it.
|
|
141
|
+
const trail = screen.path.join(' > ');
|
|
142
|
+
const header = `${screen.route ?? '?'}${trail === screen.route ? '' : ` (${trail})`}${layer}${stats}${hiddenNote}`;
|
|
143
|
+
// An index is how to point at what has no name of its own, or shares it: a control
|
|
144
|
+
// with a testID nothing else carries is pointed at by that.
|
|
145
|
+
const nameOf = (e) => (e.testId ? `#${e.testId}` : (e.text ?? e.accessibilityLabel));
|
|
146
|
+
const uses = new Map();
|
|
147
|
+
for (const e of listed) {
|
|
148
|
+
const name = nameOf(e);
|
|
149
|
+
if (name !== undefined)
|
|
150
|
+
uses.set(name, (uses.get(name) ?? 0) + 1);
|
|
151
|
+
}
|
|
152
|
+
const lines = listed.slice(0, limit).map((e) => {
|
|
153
|
+
const id = e.testId ? ` #${e.testId}` : '';
|
|
154
|
+
const name = nameOf(e);
|
|
155
|
+
const index = name !== undefined && uses.get(name) === 1 ? '' : `${e.i} `;
|
|
156
|
+
// A label that only repeats the testID says nothing new.
|
|
157
|
+
const label = (e.text ?? e.accessibilityLabel) === e.testId ? undefined : (e.text ?? e.accessibilityLabel);
|
|
158
|
+
const value = e.kind === 'input'
|
|
159
|
+
? ` value=${quote(e.value ?? '')}${e.placeholder ? ` placeholder=${quote(e.placeholder)}` : ''}${e.focused ? ' *focused' : ''}`
|
|
160
|
+
: e.value !== undefined
|
|
161
|
+
? ` value=${quote(e.value)}` // a QR's encoded string, say
|
|
162
|
+
: '';
|
|
163
|
+
const state = `${e.enabled ? '' : ' !disabled'}${e.covered ? ' ~covered' : ''}${e.hidden ? ` ~${e.hidden}` : ''}`;
|
|
164
|
+
const at = frames ? ` @${Math.round(e.frame.x + e.frame.w / 2)},${Math.round(e.frame.y + e.frame.h / 2)}` : '';
|
|
165
|
+
return `${index}${e.kind}${id}${label ? ` ${quote(label)}` : ''}${value}${state}${at}`;
|
|
166
|
+
});
|
|
167
|
+
const rest = listed.length - lines.length;
|
|
168
|
+
return [header, ...lines, rest > 0 ? `… ${rest} more` : ''].filter(Boolean).join('\n');
|
|
169
|
+
};
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { Platform } from './devices.js';
|
|
2
|
+
export type PlatformLifecycle = {
|
|
3
|
+
launchApp(deviceId: string, appId: string, fresh: boolean): Promise<void>;
|
|
4
|
+
screenshot(deviceId: string, destination: string): Promise<void>;
|
|
5
|
+
freezeChrome(deviceId: string): Promise<void>;
|
|
6
|
+
};
|
|
7
|
+
export declare const lifecycleFor: (platform: Platform) => PlatformLifecycle;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { writeFile } from 'node:fs/promises';
|
|
3
|
+
import { promisify } from 'node:util';
|
|
4
|
+
const run = promisify(execFile);
|
|
5
|
+
const ios = {
|
|
6
|
+
async launchApp(deviceId, appId, fresh) {
|
|
7
|
+
const args = ['simctl', 'launch'];
|
|
8
|
+
if (fresh)
|
|
9
|
+
args.push('--terminate-running-process');
|
|
10
|
+
await run('xcrun', [...args, deviceId, appId]);
|
|
11
|
+
},
|
|
12
|
+
async screenshot(deviceId, destination) {
|
|
13
|
+
await run('xcrun', ['simctl', 'io', deviceId, 'screenshot', destination]);
|
|
14
|
+
},
|
|
15
|
+
async freezeChrome(deviceId) {
|
|
16
|
+
await run('xcrun', [
|
|
17
|
+
'simctl',
|
|
18
|
+
'status_bar',
|
|
19
|
+
deviceId,
|
|
20
|
+
'override',
|
|
21
|
+
'--time',
|
|
22
|
+
'9:41',
|
|
23
|
+
'--batteryState',
|
|
24
|
+
'charged',
|
|
25
|
+
'--batteryLevel',
|
|
26
|
+
'100',
|
|
27
|
+
'--cellularMode',
|
|
28
|
+
'active',
|
|
29
|
+
'--cellularBars',
|
|
30
|
+
'4',
|
|
31
|
+
'--wifiMode',
|
|
32
|
+
'active',
|
|
33
|
+
'--wifiBars',
|
|
34
|
+
'3',
|
|
35
|
+
]);
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
const adb = (deviceId, args) => run('adb', ['-s', deviceId, ...args]);
|
|
39
|
+
const android = {
|
|
40
|
+
async launchApp(deviceId, appId, fresh) {
|
|
41
|
+
if (fresh)
|
|
42
|
+
await adb(deviceId, ['shell', 'am', 'force-stop', appId]);
|
|
43
|
+
await adb(deviceId, ['shell', 'monkey', '-p', appId, '-c', 'android.intent.category.LAUNCHER', '1']);
|
|
44
|
+
},
|
|
45
|
+
async screenshot(deviceId, destination) {
|
|
46
|
+
const { stdout } = await run('adb', ['-s', deviceId, 'exec-out', 'screencap', '-p'], { encoding: 'buffer', maxBuffer: 64 * 1024 * 1024 });
|
|
47
|
+
await writeFile(destination, stdout);
|
|
48
|
+
},
|
|
49
|
+
async freezeChrome(deviceId) {
|
|
50
|
+
await adb(deviceId, ['shell', 'settings', 'put', 'global', 'sysui_demo_allowed', '1']);
|
|
51
|
+
const demo = (args) => adb(deviceId, ['shell', 'am', 'broadcast', '-a', 'com.android.systemui.demo', ...args]);
|
|
52
|
+
await demo(['-e', 'command', 'enter']);
|
|
53
|
+
await demo(['-e', 'command', 'clock', '-e', 'hhmm', '0941']);
|
|
54
|
+
await demo(['-e', 'command', 'battery', '-e', 'level', '100', '-e', 'plugged', 'false']);
|
|
55
|
+
await demo(['-e', 'command', 'network', '-e', 'wifi', 'show', '-e', 'level', '4']);
|
|
56
|
+
await demo(['-e', 'command', 'notifications', '-e', 'visible', 'false']);
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
export const lifecycleFor = (platform) => (platform === 'ios' ? ios : android);
|
package/lib/files.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export declare const readJson: <T>(file: string) => T;
|
|
2
|
+
export declare const writeJson: (file: string, value: unknown) => void;
|
|
3
|
+
export declare const readJsonl: <T>(file: string) => T[];
|
|
4
|
+
export declare const appendJsonl: (file: string, row: unknown) => void;
|
|
5
|
+
export declare const writeJsonl: (file: string, rows: unknown[]) => void;
|
|
6
|
+
export declare const today: () => string;
|
|
7
|
+
export declare const stamp: () => string;
|
package/lib/files.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
export const readJson = (file) => JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
4
|
+
export const writeJson = (file, value) => {
|
|
5
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
6
|
+
fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`);
|
|
7
|
+
};
|
|
8
|
+
export const readJsonl = (file) => {
|
|
9
|
+
if (!fs.existsSync(file))
|
|
10
|
+
return [];
|
|
11
|
+
return fs
|
|
12
|
+
.readFileSync(file, 'utf8')
|
|
13
|
+
.split('\n')
|
|
14
|
+
.filter((line) => line.trim())
|
|
15
|
+
.map((line) => JSON.parse(line));
|
|
16
|
+
};
|
|
17
|
+
export const appendJsonl = (file, row) => {
|
|
18
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
19
|
+
fs.appendFileSync(file, `${JSON.stringify(row)}\n`);
|
|
20
|
+
};
|
|
21
|
+
// Through a temporary file and a rename, so a reader never sees half a file.
|
|
22
|
+
export const writeJsonl = (file, rows) => {
|
|
23
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
24
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
25
|
+
fs.writeFileSync(tmp, rows.map((row) => `${JSON.stringify(row)}\n`).join(''));
|
|
26
|
+
fs.renameSync(tmp, file);
|
|
27
|
+
};
|
|
28
|
+
export const today = () => new Date().toISOString().slice(0, 10);
|
|
29
|
+
export const stamp = () => new Date().toISOString().replace(/[:.]/g, '-');
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { Element, Screen } from 'lynkeus-protocol';
|
|
2
|
+
export declare const runDir: (root: string, flowName: string) => string;
|
|
3
|
+
export declare const stepFile: (dir: string, index: number, extension: string) => string;
|
|
4
|
+
export type ScreenDiff = {
|
|
5
|
+
route: {
|
|
6
|
+
before?: string;
|
|
7
|
+
after?: string;
|
|
8
|
+
changed: boolean;
|
|
9
|
+
};
|
|
10
|
+
added: Element[];
|
|
11
|
+
removed: Element[];
|
|
12
|
+
changedText: {
|
|
13
|
+
before: Element;
|
|
14
|
+
after: Element;
|
|
15
|
+
}[];
|
|
16
|
+
};
|
|
17
|
+
/** Structural difference between two screens: no geometry, so layout jitter is not a change. */
|
|
18
|
+
export declare const diffScreens: (before: Screen, after: Screen) => ScreenDiff;
|
|
19
|
+
export declare const describeDiff: (diff: ScreenDiff) => string;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { stamp } from '../files.js';
|
|
4
|
+
export const runDir = (root, flowName) => {
|
|
5
|
+
const slug = flowName
|
|
6
|
+
.toLowerCase()
|
|
7
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
8
|
+
.replace(/^-|-$/g, '');
|
|
9
|
+
const dir = path.join(root, slug, stamp());
|
|
10
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
11
|
+
return dir;
|
|
12
|
+
};
|
|
13
|
+
export const stepFile = (dir, index, extension) => path.join(dir, `step-${String(index).padStart(2, '0')}.${extension}`);
|
|
14
|
+
const key = (e) => `${e.kind}|${e.testId ?? ''}|${e.text ?? e.accessibilityLabel ?? ''}`;
|
|
15
|
+
/** Structural difference between two screens: no geometry, so layout jitter is not a change. */
|
|
16
|
+
export const diffScreens = (before, after) => {
|
|
17
|
+
const beforeByKey = new Map(before.elements.map((e) => [key(e), e]));
|
|
18
|
+
const afterByKey = new Map(after.elements.map((e) => [key(e), e]));
|
|
19
|
+
const added = after.elements.filter((e) => !beforeByKey.has(key(e)));
|
|
20
|
+
const removed = before.elements.filter((e) => !afterByKey.has(key(e)));
|
|
21
|
+
// Same identity (testId) but different text: a value or a label changed.
|
|
22
|
+
const changedText = [];
|
|
23
|
+
const removedById = new Map(removed.filter((e) => e.testId).map((e) => [e.testId, e]));
|
|
24
|
+
for (const e of added) {
|
|
25
|
+
const previous = e.testId ? removedById.get(e.testId) : undefined;
|
|
26
|
+
if (previous)
|
|
27
|
+
changedText.push({ before: previous, after: e });
|
|
28
|
+
}
|
|
29
|
+
const paired = new Set(changedText.flatMap((c) => [c.before, c.after]));
|
|
30
|
+
return {
|
|
31
|
+
route: { before: before.route, after: after.route, changed: before.route !== after.route },
|
|
32
|
+
added: added.filter((e) => !paired.has(e)),
|
|
33
|
+
removed: removed.filter((e) => !paired.has(e)),
|
|
34
|
+
changedText,
|
|
35
|
+
};
|
|
36
|
+
};
|
|
37
|
+
const label = (e) => `${e.kind} ${e.testId ? `#${e.testId}` : ''} ${e.text ? JSON.stringify(e.text.slice(0, 60)) : ''}`.trim();
|
|
38
|
+
export const describeDiff = (diff) => {
|
|
39
|
+
const lines = [];
|
|
40
|
+
if (diff.route.changed)
|
|
41
|
+
lines.push(`route ${diff.route.before ?? '?'} → ${diff.route.after ?? '?'}`);
|
|
42
|
+
for (const c of diff.changedText)
|
|
43
|
+
lines.push(`~ ${label(c.before)} → ${JSON.stringify(c.after.text ?? '')}`);
|
|
44
|
+
for (const e of diff.added)
|
|
45
|
+
lines.push(`+ ${label(e)}`);
|
|
46
|
+
for (const e of diff.removed)
|
|
47
|
+
lines.push(`- ${label(e)}`);
|
|
48
|
+
return lines.length ? lines.join('\n') : '(no structural change)';
|
|
49
|
+
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Device } from '../device/driver.js';
|
|
2
|
+
import type { Flow, FlowReport, StepReport } from './types.js';
|
|
3
|
+
export type RunFlowOptions = {
|
|
4
|
+
device: Device;
|
|
5
|
+
vars?: Record<string, unknown>;
|
|
6
|
+
/** Where to leave each step's screen, and screenshot when asked; nothing is kept without one. */
|
|
7
|
+
artifactsRoot?: string;
|
|
8
|
+
screenshots?: boolean;
|
|
9
|
+
/** Drain the app's `events` after every step and keep them with the run; it empties the app's buffer. */
|
|
10
|
+
events?: boolean;
|
|
11
|
+
onStep?: (report: StepReport, index: number) => void;
|
|
12
|
+
};
|
|
13
|
+
export declare const runFlow: (flow: Flow, options: RunFlowOptions) => Promise<FlowReport>;
|