@delacour/warden-e2e 0.0.0-stage → 0.1.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.
Files changed (115) hide show
  1. package/LICENSE +21 -0
  2. package/chunk-e6pn5ygy.mjs +5832 -0
  3. package/index.d.ts +1 -0
  4. package/index.mjs +1047 -0
  5. package/package.json +49 -4
  6. package/preload.d.ts +1 -0
  7. package/preload.mjs +4788 -0
  8. package/types/core/src/allocate.d.ts +23 -0
  9. package/types/core/src/builds/app-option.schema.d.ts +37 -0
  10. package/types/core/src/builds/install.d.ts +95 -0
  11. package/types/core/src/builds/resolve.d.ts +74 -0
  12. package/types/core/src/config.defaults.d.ts +25 -0
  13. package/types/core/src/exec.d.ts +18 -0
  14. package/types/core/src/liveness.d.ts +12 -0
  15. package/types/core/src/providers/android.d.ts +132 -0
  16. package/types/core/src/providers/health.d.ts +45 -0
  17. package/types/core/src/providers/provider.types.d.ts +56 -0
  18. package/types/core/src/store.d.ts +105 -0
  19. package/types/core/src/types.d.ts +126 -0
  20. package/types/e2e/src/alert-buttons.d.ts +41 -0
  21. package/types/e2e/src/alert-reader.d.ts +25 -0
  22. package/types/e2e/src/app.d.ts +62 -0
  23. package/types/e2e/src/device-devtools.d.ts +26 -0
  24. package/types/e2e/src/device-link.d.ts +43 -0
  25. package/types/e2e/src/device.d.ts +90 -0
  26. package/types/e2e/src/devtools/cdp.d.ts +65 -0
  27. package/types/e2e/src/devtools/console.d.ts +59 -0
  28. package/types/e2e/src/devtools/devtools.types.d.ts +169 -0
  29. package/types/e2e/src/devtools/facet.d.ts +28 -0
  30. package/types/e2e/src/devtools/inspector.d.ts +48 -0
  31. package/types/e2e/src/devtools/jsonl.d.ts +4 -0
  32. package/types/e2e/src/devtools/native-log.d.ts +50 -0
  33. package/types/e2e/src/devtools/network.d.ts +33 -0
  34. package/types/e2e/src/devtools/network.script.d.ts +24 -0
  35. package/types/e2e/src/devtools/session.d.ts +82 -0
  36. package/types/e2e/src/devtools/tree.d.ts +23 -0
  37. package/types/e2e/src/devtools/tree.script.d.ts +33 -0
  38. package/types/e2e/src/driver/alerts.d.ts +29 -0
  39. package/types/e2e/src/driver/android/adb-session.d.ts +52 -0
  40. package/types/e2e/src/driver/android/android-alert.d.ts +4 -0
  41. package/types/e2e/src/driver/android/android-animations.d.ts +29 -0
  42. package/types/e2e/src/driver/android/android-app.d.ts +32 -0
  43. package/types/e2e/src/driver/android/android-argv.d.ts +122 -0
  44. package/types/e2e/src/driver/android/android-device.d.ts +44 -0
  45. package/types/e2e/src/driver/android/android-driver.d.ts +60 -0
  46. package/types/e2e/src/driver/android/android-dump.d.ts +46 -0
  47. package/types/e2e/src/driver/android/android-state.d.ts +69 -0
  48. package/types/e2e/src/driver/android/uiad-backend.d.ts +63 -0
  49. package/types/e2e/src/driver/android/uiad-client.d.ts +60 -0
  50. package/types/e2e/src/driver/android/uiad-device.d.ts +70 -0
  51. package/types/e2e/src/driver/android/uiad-dex.generated.d.ts +8 -0
  52. package/types/e2e/src/driver/android/uiad-ops.d.ts +62 -0
  53. package/types/e2e/src/driver/android/uiad-protocol.d.ts +209 -0
  54. package/types/e2e/src/driver/android/uiautomator.d.ts +33 -0
  55. package/types/e2e/src/driver/driver.types.d.ts +231 -0
  56. package/types/e2e/src/driver/errors.d.ts +13 -0
  57. package/types/e2e/src/driver/gesture-geometry.d.ts +48 -0
  58. package/types/e2e/src/driver/ios/hid-recovery.d.ts +36 -0
  59. package/types/e2e/src/driver/ios/ios-driver.d.ts +63 -0
  60. package/types/e2e/src/driver/ios/ios-session.d.ts +30 -0
  61. package/types/e2e/src/driver/ios/locate-helper.d.ts +38 -0
  62. package/types/e2e/src/driver/ios/normalize.d.ts +11 -0
  63. package/types/e2e/src/driver/ios/simctl.d.ts +102 -0
  64. package/types/e2e/src/driver/ios/simd-client.d.ts +68 -0
  65. package/types/e2e/src/driver/ios/simd-protocol.d.ts +433 -0
  66. package/types/e2e/src/driver/ios/simd-transport.d.ts +43 -0
  67. package/types/e2e/src/driver/ios/type-budget.d.ts +6 -0
  68. package/types/e2e/src/driver/observation-cache.d.ts +66 -0
  69. package/types/e2e/src/driver/open.d.ts +29 -0
  70. package/types/e2e/src/driver/settle.d.ts +43 -0
  71. package/types/e2e/src/driver/span-detail.d.ts +12 -0
  72. package/types/e2e/src/driver/traced-driver.d.ts +14 -0
  73. package/types/e2e/src/driver/typing.d.ts +48 -0
  74. package/types/e2e/src/driver/unwrap.d.ts +13 -0
  75. package/types/e2e/src/env.d.ts +78 -0
  76. package/types/e2e/src/errors.d.ts +26 -0
  77. package/types/e2e/src/expect/expect.d.ts +27 -0
  78. package/types/e2e/src/expect/matchers.d.ts +72 -0
  79. package/types/e2e/src/fixture-session.d.ts +38 -0
  80. package/types/e2e/src/fixtures.d.ts +14 -0
  81. package/types/e2e/src/index.d.ts +11 -0
  82. package/types/e2e/src/perf/budgets.d.ts +33 -0
  83. package/types/e2e/src/perf/device-perf.d.ts +45 -0
  84. package/types/e2e/src/perf/perf.types.d.ts +59 -0
  85. package/types/e2e/src/preload.d.ts +29 -0
  86. package/types/e2e/src/runner/artifacts.d.ts +13 -0
  87. package/types/e2e/src/runner/attempt.d.ts +63 -0
  88. package/types/e2e/src/runner/callsite.d.ts +2 -0
  89. package/types/e2e/src/runner/failure.d.ts +40 -0
  90. package/types/e2e/src/runner/fixtures.types.d.ts +60 -0
  91. package/types/e2e/src/runner/results.d.ts +48 -0
  92. package/types/e2e/src/runner/scope.d.ts +64 -0
  93. package/types/e2e/src/runner/span.d.ts +40 -0
  94. package/types/e2e/src/runner/step.d.ts +7 -0
  95. package/types/e2e/src/runner/test.d.ts +59 -0
  96. package/types/e2e/src/runner/trace.d.ts +87 -0
  97. package/types/e2e/src/screen/actions.d.ts +93 -0
  98. package/types/e2e/src/screen/context.d.ts +134 -0
  99. package/types/e2e/src/screen/field.d.ts +121 -0
  100. package/types/e2e/src/screen/locator.d.ts +233 -0
  101. package/types/e2e/src/screen/nodes.d.ts +53 -0
  102. package/types/e2e/src/screen/poll.d.ts +57 -0
  103. package/types/e2e/src/screen/queries.d.ts +34 -0
  104. package/types/e2e/src/screen/query.d.ts +60 -0
  105. package/types/e2e/src/screen/react-locator.d.ts +30 -0
  106. package/types/e2e/src/screen/render.d.ts +42 -0
  107. package/types/e2e/src/screen/roles.d.ts +24 -0
  108. package/types/e2e/src/screen/screen.d.ts +61 -0
  109. package/types/e2e/src/screen/selector.d.ts +38 -0
  110. package/types/e2e/src/screen/text.d.ts +13 -0
  111. package/types/e2e/src/session.d.ts +124 -0
  112. package/types/e2e/src/traced-device.d.ts +9 -0
  113. package/types/e2e/src/version.d.ts +7 -0
  114. package/types/types/src/result.d.ts +12 -0
  115. package/README.md +0 -3
@@ -0,0 +1,23 @@
1
+ import { type PidAlive } from "./liveness";
2
+ import type { AllocationPlan, DeviceRequest, InventoryDevice, Lease } from "./types";
3
+ export type AllocateInput = {
4
+ inventory: InventoryDevice[];
5
+ leases: Lease[];
6
+ request: DeviceRequest;
7
+ now: number;
8
+ pidAlive: PidAlive;
9
+ /** device ids to leave out this round (failed their health probe in this claim) */
10
+ skip?: ReadonlySet<string>;
11
+ };
12
+ /** `iPhone 17 Pro` → `iphone-17-pro`. */
13
+ export declare function profileSlug(name: string): string;
14
+ export declare function wardenDeviceName(profile: string, index: number): string;
15
+ /** cores/4, at least 1, capped at 4 (salient's shard heuristic). */
16
+ export declare function defaultMax(cores: number): number;
17
+ export declare function compareRuntimeDesc(a: InventoryDevice, b: InventoryDevice): number;
18
+ /**
19
+ * Pure allocation: which devices satisfy `request` given current leases. Free warden devices first
20
+ * (booted before shutdown, newest runtime first), then pool growth up to `max`. Foreign devices are
21
+ * only considered with `adopt`. Stale leases are ignored and returned for reclaim.
22
+ */
23
+ export declare function allocate({ inventory, leases, request, now, pidAlive, skip }: AllocateInput): AllocationPlan;
@@ -0,0 +1,37 @@
1
+ import { z } from "zod";
2
+ /**
3
+ * What to do with the app's installed state before a run:
4
+ * - unset: install only when the device lacks this build; a different build is installed over the old one.
5
+ * - `"clean"`: fresh app data every run without reinstalling an unchanged build.
6
+ * - `"reinstall"`: uninstall, then install, every run.
7
+ */
8
+ export declare const installModeSchema: z.ZodEnum<{
9
+ clean: "clean";
10
+ reinstall: "reinstall";
11
+ }>;
12
+ export type InstallMode = z.infer<typeof installModeSchema>;
13
+ /**
14
+ * `app` on a batch preset / e2e suite: `true` installs the project's app on every device first;
15
+ * `{ installMode }` also resets the installed app (see {@link InstallMode}); `{ resetKeychain: true }`
16
+ * (needs `installMode`) also resets the iOS simulator's keychain;
17
+ * `{ variant }` installs that `projects[].variants` build instead of the project's default one.
18
+ * `{ clean: true }` is the deprecated spelling of `{ installMode: "reinstall" }`.
19
+ */
20
+ export declare const appOptionSchema: z.ZodUnion<readonly [z.ZodBoolean, z.ZodObject<{
21
+ installMode: z.ZodOptional<z.ZodEnum<{
22
+ clean: "clean";
23
+ reinstall: "reinstall";
24
+ }>>;
25
+ resetKeychain: z.ZodOptional<z.ZodBoolean>;
26
+ clean: z.ZodOptional<z.ZodBoolean>;
27
+ variant: z.ZodOptional<z.ZodString>;
28
+ }, z.core.$strict>]>;
29
+ export type AppOption = z.infer<typeof appOptionSchema>;
30
+ export type AppSwitches = {
31
+ app: boolean;
32
+ installMode?: InstallMode;
33
+ resetKeychain?: true;
34
+ variant?: string;
35
+ };
36
+ /** The switches `app` stands for. An object means install (`app: {}` = `app: true`). */
37
+ export declare function appSwitches(app: AppOption | undefined): AppSwitches;
@@ -0,0 +1,95 @@
1
+ import { type AsyncResult } from "../../../types/src/result";
2
+ import { type Exec } from "../exec";
3
+ import type { Platform } from "../types";
4
+ import type { InstalledApp } from "./resolve";
5
+ /** Byte size of a file; undefined when it cannot be read. Injected so identity checks need no `stat` spawns. */
6
+ export type FileSize = (path: string) => Promise<number | undefined>;
7
+ export declare const nodeFileSize: FileSize;
8
+ /** `ino:mtime` of a file; undefined when it cannot be read. Injected so tests need no real container. */
9
+ export type FileStamp = (path: string) => Promise<string | undefined>;
10
+ export declare const nodeFileStamp: FileStamp;
11
+ /**
12
+ * `ino:mtime` of an installed `.app`'s executable, guessed as the bundle's own name (`App.app/App`, what
13
+ * Xcode's `EXECUTABLE_NAME` defaults to) so no `plutil` is needed; undefined when that file is not there.
14
+ */
15
+ export declare function executableStamp(container: string, fileStamp?: FileStamp): Promise<string | undefined>;
16
+ export type InstallerDeps = {
17
+ exec: Exec;
18
+ env: Record<string, string | undefined>;
19
+ /** default: `node:fs` */
20
+ fileSize?: FileSize;
21
+ };
22
+ /** A device to install onto: iOS simulator udid / Android emulator serial. */
23
+ export type InstallTarget = {
24
+ platform: Platform;
25
+ deviceId: string;
26
+ };
27
+ /** `xcrun simctl install <udid> <app>` / `adb -s <serial> install -r <apk>`. */
28
+ export declare function installArgv(target: InstallTarget, appPath: string, env: InstallerDeps["env"]): string[];
29
+ /** `xcrun simctl get_app_container <udid> <bundleId>` / `adb -s <serial> shell pm path <bundleId>`. */
30
+ export declare function probeArgv(target: InstallTarget, bundleId: string, env: InstallerDeps["env"]): string[];
31
+ /**
32
+ * Is `bundleId` on the device, and (iOS) where is its container? One `simctl get_app_container` /
33
+ * `pm path` — hand the container to `appMatchesArtifact` instead of asking again. simctl exits
34
+ * non-zero / `pm path` prints nothing when the app is not there.
35
+ */
36
+ export declare function probeApp(deps: InstallerDeps, target: InstallTarget, bundleId: string): AsyncResult<InstalledApp>;
37
+ /** Is `bundleId` installed on the device? */
38
+ export declare function isAppInstalled(deps: InstallerDeps, target: InstallTarget, bundleId: string): AsyncResult<boolean>;
39
+ /**
40
+ * What distinguishes one build of an `.app` from another, cheaply: `CFBundleVersion` plus the byte
41
+ * size of the main executable and of the embedded `main.jsbundle` (Release). Size is used instead
42
+ * of mtime because `simctl install` re-copies the bundle, so the installed mtime need not match the
43
+ * cached one. One `plutil` spawn; the sizes are read in-process. undefined when the bundle can't be read.
44
+ */
45
+ export declare function appIdentity(deps: InstallerDeps, appDir: string): Promise<string | undefined>;
46
+ /**
47
+ * Is the app on the device the cached `artifactPath` build? iOS: compare `appIdentity` of the
48
+ * installed container with the cached `.app`; pass `installedDir` (the container `probeApp` already
49
+ * found) to skip a second `simctl get_app_container`. Anything unreadable counts as a mismatch
50
+ * (reinstall is the safe side). Android: not checked (always true) — warden's install record is
51
+ * trusted there.
52
+ */
53
+ export declare function appMatchesArtifact(deps: InstallerDeps, target: InstallTarget, bundleId: string, artifactPath: string, installedDir?: string): AsyncResult<boolean>;
54
+ /** `xcrun simctl uninstall <udid> <bundleId>` / `adb -s <serial> uninstall <bundleId>`. */
55
+ export declare function uninstallArgv(target: InstallTarget, bundleId: string, env: InstallerDeps["env"]): string[];
56
+ /**
57
+ * Remove `bundleId` (and with it the app container: data, keychain-less state) so the next install
58
+ * starts fresh. Absent already is fine; present after the uninstall is an error.
59
+ */
60
+ export declare function uninstallApp(deps: InstallerDeps, target: InstallTarget, bundleId: string): AsyncResult<void>;
61
+ /** Install `appPath`, then confirm `bundleId` is present. */
62
+ export declare function installApp(deps: InstallerDeps, target: InstallTarget, appPath: string, bundleId: string): AsyncResult<void>;
63
+ /** `installApp`, keeping what the confirming probe found (iOS: the new bundle container, 17-7). */
64
+ export declare function installAndProbe(deps: InstallerDeps, target: InstallTarget, appPath: string, bundleId: string): AsyncResult<InstalledApp>;
65
+ /** Filesystem ops the iOS data wipe needs (injected; tests pass fakes). */
66
+ export type ResetFs = {
67
+ readdir: (dir: string) => Promise<string[]>;
68
+ /** Recursive, force. */
69
+ rm: (path: string) => Promise<void>;
70
+ /** Recursive. */
71
+ mkdir: (path: string) => Promise<void>;
72
+ };
73
+ export type ResetDeps = InstallerDeps & {
74
+ fs: ResetFs;
75
+ };
76
+ export type ResetOptions = {
77
+ keychain?: boolean;
78
+ };
79
+ /** `node:fs/promises`-backed `ResetFs`. */
80
+ export declare const nodeResetFs: ResetFs;
81
+ /**
82
+ * Android: `adb -s <serial> shell pm clear <bundleId>`. iOS: `simctl privacy <udid> reset all <bundleId>`
83
+ * (the last argv-only step of the multi-step iOS reset; see `resetAppData`).
84
+ */
85
+ export declare function resetArgv(target: InstallTarget, bundleId: string, env: InstallerDeps["env"]): string[];
86
+ /** iOS: `simctl keychain <udid> reset` (the whole simulator's keychain; an uninstall leaves it). Android: nothing. */
87
+ export declare function resetSimKeychain(deps: InstallerDeps, target: InstallTarget): AsyncResult<void>;
88
+ /**
89
+ * Reset an installed app to first-launch state without reinstalling. Not installed → error (the
90
+ * caller installs instead). Android: `pm clear` (data, permissions; keychain n/a). iOS: terminate,
91
+ * `defaults delete` (via cfprefsd; deleting the plist alone is undone by its cache), wipe the data
92
+ * and app-group containers in place (the metadata plist stays), recreate the standard subdirs,
93
+ * reset privacy grants, and with `keychain` the simulator-wide keychain.
94
+ */
95
+ export declare function resetAppData(deps: ResetDeps, target: InstallTarget, bundleId: string, opts?: ResetOptions): AsyncResult<void>;
@@ -0,0 +1,74 @@
1
+ import { type AsyncResult } from "../../../types/src/result";
2
+ import type { InstallMode } from "./app-option.schema";
3
+ /** Where `ensure` found the app. */
4
+ export type ResolveSource = "installed" | "cache" | "eas" | "build";
5
+ export type EnsureResult = {
6
+ /** cached `.app`/`.apk` ("" only when already installed and the cache no longer has it) */
7
+ appPath: string;
8
+ hash: string;
9
+ source: ResolveSource;
10
+ /** the app is on the device at `hash` (false when no device was given) */
11
+ installed: boolean;
12
+ };
13
+ export type EasStepOutcome = {
14
+ kind: "hit";
15
+ path: string;
16
+ } | {
17
+ kind: "miss";
18
+ reason: string;
19
+ };
20
+ /** What the device probe found: is the app there, and (iOS) its container so `matches` need not ask again. */
21
+ export type InstalledApp = {
22
+ present: boolean;
23
+ container?: string;
24
+ };
25
+ /** Device half of the resolver — absent when only the artifact is wanted. */
26
+ export type DeviceSteps = {
27
+ /** hash recorded in the `installs` table for this device + bundle id */
28
+ installedHash: () => string | undefined;
29
+ /** does the device really have the bundle id (simctl get_app_container / pm path)? */
30
+ confirm: () => AsyncResult<InstalledApp>;
31
+ /** does the installed app (as `confirm` found it) match the cached build at `cachedPath` (CFBundleVersion + binary)? Absent = trust the record. */
32
+ matches?: (cachedPath: string, installed: InstalledApp) => AsyncResult<boolean>;
33
+ /** remove the app (and its data) so the install starts fresh; absent is fine */
34
+ uninstall: () => AsyncResult<void>;
35
+ /** `installMode: "clean"`: wipe the installed app's data (and the keychain when asked), keeping the binary */
36
+ reset: () => AsyncResult<void>;
37
+ /** install + record in `installs` */
38
+ install: (appPath: string) => AsyncResult<void>;
39
+ };
40
+ /**
41
+ * The resolver's effects, each a thin closure — tests pass fakes. `eas` / `build` absent means
42
+ * skipped (`--no-eas` / `--no-build`, or EAS not configured).
43
+ */
44
+ export type ResolveSteps = {
45
+ hash: string;
46
+ device?: DeviceSteps;
47
+ /** `clean`: fresh app data without reinstalling an unchanged build; `reinstall`: uninstall + install even at `hash`; unset: install only when `hash` differs */
48
+ installMode?: InstallMode;
49
+ /** cached artifact path for `hash` (touches lastUsedAt) */
50
+ cached: () => string | undefined;
51
+ /** import `hash` from a legacy cache dir into the cache; path when found */
52
+ legacy: () => AsyncResult<string | undefined>;
53
+ /** serialise the fetch/build of `hash` across processes */
54
+ lock: <T>(fn: () => AsyncResult<T>) => AsyncResult<T>;
55
+ /**
56
+ * Same-process single flight: concurrent `resolveApp`s with this key (one per device of a session)
57
+ * share the first one's locked fetch/build and its result, success or error; only other processes
58
+ * wait on the build lock. Absent: every call takes the lock itself.
59
+ */
60
+ flightKey?: string;
61
+ /** EAS: download into the cache, or miss */
62
+ eas?: () => AsyncResult<EasStepOutcome>;
63
+ /** local build into the cache; path */
64
+ build?: () => AsyncResult<string>;
65
+ log: (line: string) => void;
66
+ };
67
+ /**
68
+ * The 5-step resolver, first hit wins: (1) already installed at this hash → nothing to do;
69
+ * (2) warden cache; (3) legacy cache import; then under the build lock (4) EAS, (5) local build.
70
+ * Whatever was found is installed over the old app (a hash mismatch reinstalls). `installMode: "clean"` keeps
71
+ * step 1 but resets the app's data either way; `reinstall` skips step 1 and uninstalls first. A new fingerprint
72
+ * misses 1–3 by construction, so it never gets a stale binary.
73
+ */
74
+ export declare function resolveApp(steps: ResolveSteps): AsyncResult<EnsureResult>;
@@ -0,0 +1,25 @@
1
+ /** Default lease TTL for agents/users: 30 min (plan default 2a). */
2
+ export declare const DEFAULT_TTL_MS: number;
3
+ /** Bare `warden claim` by an agent: 5 min. Only wrappers (`dev`/`run`/`batch`/`e2e`) or `heartbeat` hold longer. */
4
+ export declare const BARE_CLAIM_AGENT_TTL_MS: number;
5
+ /** `warden gc` shuts down idle warden devices after 20 min. */
6
+ export declare const DEFAULT_IDLE_MS: number;
7
+ /** `warden run` heartbeat interval. */
8
+ export declare const HEARTBEAT_INTERVAL_MS = 30000;
9
+ /** Device boot/ready timeout. */
10
+ export declare const DEFAULT_READY_TIMEOUT_MS: number;
11
+ /** Default device profile per platform. Android `auto` = first AVD from `emulator -list-avds`. */
12
+ export declare const DEFAULT_PROFILE: {
13
+ readonly ios: "iphone-17";
14
+ readonly android: "auto";
15
+ };
16
+ /** Where a warden-owned Metro leases its port from (`warden dev`, `e2e.<suite>.metro`). */
17
+ export declare const DEFAULT_METRO_PORT_SPEC = "8081:100";
18
+ /** How long to wait for an owned Metro to serve its project. */
19
+ export declare const DEFAULT_METRO_READY_TIMEOUT = "2m";
20
+ /** `e2e.<suite>.jobTimeoutMs`: a spec process still going after this is killed. */
21
+ export declare const DEFAULT_JOB_TIMEOUT_MS: number;
22
+ /** `e2e.<suite>.spec` defaults: per-test, per-action and per-assertion timeouts. */
23
+ export declare const DEFAULT_SPEC_TIMEOUT = "2m";
24
+ export declare const DEFAULT_SPEC_ACTION_TIMEOUT = "30s";
25
+ export declare const DEFAULT_SPEC_ASSERTION_TIMEOUT = "5s";
@@ -0,0 +1,18 @@
1
+ export type ExecResult = {
2
+ exitCode: number;
3
+ stdout: string;
4
+ stderr: string;
5
+ };
6
+ export type ExecOptions = {
7
+ cwd?: string;
8
+ env?: Record<string, string | undefined>;
9
+ timeoutMs?: number;
10
+ /** aborting kills the command (SIGTERM), like a timeout */
11
+ signal?: AbortSignal;
12
+ };
13
+ /** Injectable process runner — providers take this so tests can fake `simctl`/`adb`. */
14
+ export type Exec = (cmd: readonly string[], opts?: ExecOptions) => Promise<ExecResult>;
15
+ /** Never throws: a command that cannot start (missing binary, missing cwd) is exit 127 with the reason on stderr. */
16
+ export declare const bunExec: Exec;
17
+ /** `cmd` failed → one-line error string for a `Result`. */
18
+ export declare function execError(cmd: readonly string[], result: ExecResult): string;
@@ -0,0 +1,12 @@
1
+ import type { Lease } from "./types";
2
+ export type PidAlive = (pid: number) => boolean;
3
+ /** Heartbeats a supervised lease may miss while its pid is dead before it is stale (suspend/resume grace). */
4
+ export declare const LOST_HEARTBEATS = 3;
5
+ /**
6
+ * Lease alive = pid alive || heartbeat within ttl. A supervised lease (pid + `heartbeatMs`, held by a
7
+ * heartbeating `run` / `dev` / `batch` / `e2e`) whose pid is dead is alive only within
8
+ * `LOST_HEARTBEATS` periods of its last heartbeat (and the ttl). Pure: pid check injected.
9
+ */
10
+ export declare function isLeaseAlive(lease: Lease, now: number, pidAlive: PidAlive): boolean;
11
+ /** `kill(pid, 0)` probe: EPERM means the process exists but belongs to someone else. */
12
+ export declare function processAlive(pid: number): boolean;
@@ -0,0 +1,132 @@
1
+ import { type AsyncResult, type Result } from "../../../types/src/result";
2
+ import { type PidAlive } from "../liveness";
3
+ import type { Lease } from "../types";
4
+ import type { DeviceProvider, ProviderDeps } from "./provider.types";
5
+ /** Emulator console ports: even, 5554–5584; the adb port is console + 1. */
6
+ export declare const CONSOLE_PORT_MIN = 5554;
7
+ export declare const CONSOLE_PORT_MAX = 5584;
8
+ export type AndroidTools = {
9
+ adb: string;
10
+ emulator: string;
11
+ };
12
+ /** `adb`/`emulator` from `$ANDROID_HOME` / `$ANDROID_SDK_ROOT`, else bare PATH names. */
13
+ export declare function androidTools(env: Record<string, string | undefined>): AndroidTools;
14
+ export type AdbEmulator = {
15
+ serial: string;
16
+ port: number;
17
+ state: string;
18
+ };
19
+ /** Parse `adb devices -l`, keeping only `emulator-<port>` serials (physical devices ignored). */
20
+ export declare function parseAdbDevices(stdout: string): AdbEmulator[];
21
+ /** Parse `emulator -list-avds`, dropping the emulator's own `INFO | …` / `WARNING | …` log lines. */
22
+ export declare function parseAvdList(stdout: string): string[];
23
+ /**
24
+ * First even console port in 5554–5584 where neither the console port nor its adb port (+1) is in
25
+ * `taken` (leased ports ∪ running emulator console/adb ports).
26
+ */
27
+ export declare function pickConsolePort(taken: ReadonlySet<number>): number | undefined;
28
+ type ExecDeps = Pick<ProviderDeps, "exec" | "env">;
29
+ /** `adb emu …` talks to the emulator console, which a wedged emulator never answers. */
30
+ export declare const EMU_CONSOLE_TIMEOUT_MS = 10000;
31
+ /** `emulator -list-avds` starts the emulator binary. */
32
+ export declare const LIST_AVDS_TIMEOUT_MS = 15000;
33
+ /**
34
+ * AVD names from `emulator -list-avds`, run once per process (per `exec`): a claim resolves `auto` and
35
+ * then the AVD of each create from the same list. `fresh` re-reads it (an AVD made since); failures are
36
+ * never kept.
37
+ */
38
+ export declare function listAvds(deps: ExecDeps, opts?: {
39
+ fresh?: boolean;
40
+ }): AsyncResult<string[]>;
41
+ /** Running emulators from `adb devices -l`. */
42
+ export declare function listEmulators(deps: ExecDeps): AsyncResult<AdbEmulator[]>;
43
+ /** Launch `cmd` so it outlives warden, stdout/stderr appended to `logPath`. */
44
+ export type SpawnDetached = (cmd: readonly string[], logPath: string) => AsyncResult<{
45
+ pid: number;
46
+ }>;
47
+ export type AndroidProviderOptions = {
48
+ spawnDetached?: SpawnDetached;
49
+ sleep?: (ms: number) => Promise<void>;
50
+ /** `sys.boot_completed` poll interval of the fallback loop, default 250 ms */
51
+ pollIntervalMs?: number;
52
+ /** how long the default `spawnDetached` watches for an immediate exit, default 1.5 s */
53
+ earlyExitMs?: number;
54
+ /** is the launched emulator process still alive (default: `kill -0`) */
55
+ pidAlive?: PidAlive;
56
+ /**
57
+ * Name of a warden quick-boot snapshot of `avd` to launch from (see `golden/android-golden.ts`), when
58
+ * one exists: a claim never builds one. `ok(undefined)` = none (off, not built yet, no SDK). A failure
59
+ * only logs: the emulator then cold-boots as before.
60
+ */
61
+ snapshot?: (avd: string) => AsyncResult<string | undefined>;
62
+ /** The emulator logged that `snapshot` of `avd` did not load (it cold-booted instead): drop it so it is rebuilt. */
63
+ snapshotFailed?: (avd: string, snapshot: string) => AsyncResult<unknown>;
64
+ /** The emulator launched from `snapshot` of `avd` and reached ready with no load failure logged. */
65
+ snapshotLoaded?: (avd: string, snapshot: string) => void;
66
+ };
67
+ /** One `adb shell` that returns once the guest has booted; POSIX `sh` (toybox `[`). */
68
+ export declare const BOOT_WAIT_SCRIPT = "while [ \"$(getprop sys.boot_completed)\" != 1 ]; do sleep 0.2; done";
69
+ /** The last 3 lines of an emulator log, one line. */
70
+ export declare function logTail(logPath: string): string;
71
+ /**
72
+ * Default detached launcher: node `spawn` with `detached` + `unref` (own process group, so warden
73
+ * exiting / Ctrl-C does not kill the emulator). Fails if the process exits within `earlyExitMs`.
74
+ */
75
+ export declare function createSpawnDetached(earlyExitMs?: number): SpawnDetached;
76
+ /** Cheap headless flags every warden emulator gets: no audio backend (unless `WARDEN_EMULATOR_AUDIO=1`), no usage metrics. */
77
+ export declare function headlessEmulatorFlags(env: Record<string, string | undefined>): string[];
78
+ /** An early exit whose message says the emulator does not know one of its options (an older emulator). */
79
+ export declare function rejectedOption(error: string): boolean;
80
+ /** A claim's emulator: headless, `-read-only` (one AVD backs many), from `snapshot` when given, never saving on exit. */
81
+ export declare function claimEmulatorArgv(tools: AndroidTools, env: Record<string, string | undefined>, avd: string, port: number, snapshot?: string,
82
+ /** `false` leaves out `headlessEmulatorFlags` (an emulator that rejected them) */
83
+ headless?: boolean): string[];
84
+ export type BootWaitDeps = ExecDeps & {
85
+ now: () => number;
86
+ sleep: (ms: number) => Promise<void>;
87
+ pidAlive: PidAlive;
88
+ /** fallback `getprop` poll interval */
89
+ pollIntervalMs: number;
90
+ };
91
+ /** The launched emulator process, when known: a boot wait fails as soon as it is gone. */
92
+ export type BootWatch = {
93
+ pid?: number;
94
+ logPath?: string;
95
+ };
96
+ /**
97
+ * Wait for `sys.boot_completed=1` within `timeoutMs`: one `adb wait-for-device shell <loop>` bounded by
98
+ * the budget (falling back to a bounded poll when it fails early), raced against the deadline and the
99
+ * emulator process staying alive, so a dead emulator fails in ≤ 0.5 s with its log tail instead of
100
+ * waiting out the budget. Whatever is still running when it settles is aborted.
101
+ */
102
+ export declare function waitBootCompleted(deps: BootWaitDeps, serial: string, timeoutMs: number, watch?: BootWatch): AsyncResult<void>;
103
+ /**
104
+ * Take a console-port lease (`emulator <name>`) under the store's cross-process lock: the first even
105
+ * port whose console and adb ports are neither leased nor used by a running emulator.
106
+ */
107
+ export declare function leaseConsolePort(deps: Pick<ProviderDeps, "store" | "owner" | "now">, name: string, running: AdbEmulator[]): Result<{
108
+ lease: Lease;
109
+ port: number;
110
+ }>;
111
+ /** Label prefix of the console-port lease a snapshot build's writable emulator runs on. */
112
+ export declare const GOLDEN_PORT_LABEL = "emulator golden ";
113
+ /**
114
+ * A snapshot load the emulator gave up on (it then cold-boots by itself). The wording is the emulator's
115
+ * and unverified on a device; a successful load logs `Successfully loaded snapshot …`, which does not match.
116
+ */
117
+ export declare function snapshotLoadFailed(log: string): boolean;
118
+ /**
119
+ * Global settings applied after every boot. On a fresh playstore image the first `adb install` can pop a Play
120
+ * Protect consent dialog (a GMS activity the e2e alert detector cannot classify), and every adb install otherwise
121
+ * waits on the GMS verifier (up to the 10 s verifier_timeout). Warden's emulators run `-read-only -no-snapshot-save`,
122
+ * so /data changes are a per-boot overlay and must be re-applied on each boot.
123
+ */
124
+ export declare const BOOT_SETTINGS: ReadonlyArray<readonly [key: string, value: string]>;
125
+ export declare function bootSettingsCommand(): string;
126
+ /**
127
+ * Android emulators on `adb` + `emulator`. Warden never boots an existing emulator — it launches a
128
+ * new headless `-read-only` instance of an AVD (so one AVD backs many emulators) on a leased even
129
+ * console port, and only kills emulators it launched (store `devices` table).
130
+ */
131
+ export declare function createAndroidProvider(deps: ProviderDeps, options?: AndroidProviderOptions): DeviceProvider;
132
+ export {};
@@ -0,0 +1,45 @@
1
+ import { type AsyncResult } from "../../../types/src/result";
2
+ import type { Exec, ExecResult } from "../exec";
3
+ export declare const PROBE_TIMEOUT_MS = 5000;
4
+ /** A probe that timed out is asked once more, with this much longer: a busy host is not a hung sim. */
5
+ export declare const PROBE_RETRY_FACTOR = 3;
6
+ /** A cancellable timer: `promise` resolves after `ms` unless `cancel` runs first. */
7
+ export type Timer = {
8
+ promise: Promise<void>;
9
+ cancel: () => void;
10
+ };
11
+ export declare function realTimer(ms: number): Timer;
12
+ /** An exec plus the timer that bounds it. */
13
+ export type TimedExec = {
14
+ exec: Exec;
15
+ /** the timeout timer — tests resolve it immediately instead of waiting */
16
+ timer: (ms: number) => Timer;
17
+ };
18
+ export type HealthDeps = TimedExec & {
19
+ timeoutMs?: number;
20
+ };
21
+ /** What a probe error says when the sim gave no answer in time (as opposed to a wrong answer). */
22
+ export declare const PROBE_NO_ANSWER = "did not answer within";
23
+ /** The probe failed because the sim did not answer in time, not because SpringBoard is down or launchctl failed. */
24
+ export declare function isProbeTimeout(error: string): boolean;
25
+ /**
26
+ * Run `cmd`, giving up (undefined) after `timeoutMs`. The exec gets the same timeout so a real process
27
+ * is killed; the timer race bounds an exec that never returns at all.
28
+ */
29
+ export declare function execWithin(deps: TimedExec, cmd: readonly string[], timeoutMs: number): Promise<ExecResult | undefined>;
30
+ /** `launchctl list` rows are `PID\tStatus\tLabel`; SpringBoard is a job with a numeric pid. */
31
+ export declare function springboardRunning(launchctlList: string): boolean;
32
+ /**
33
+ * iOS claim health probe: the booted simulator's launchd answers `launchctl print system` within
34
+ * the timeout (retried once, longer, so a loaded host does not get a healthy sim quarantined) and
35
+ * SpringBoard is running. A hung or half-booted sim fails one of the two. The two are independent,
36
+ * so they are asked at the same time.
37
+ */
38
+ export declare function probeIosHealth(deps: HealthDeps, udid: string): AsyncResult<void>;
39
+ /**
40
+ * Probe for a simulator that was already Booted when claimed: one `launchctl list`. It answering at
41
+ * all proves launchd is up (so `launchctl print system` and `bootstatus` would add nothing), and
42
+ * SpringBoard running proves the boot finished. A failure is not a verdict — the caller falls back to
43
+ * `bootstatus` + the full probe, since a sim another process was still booting looks like this.
44
+ */
45
+ export declare function probeIosWarm(deps: HealthDeps, udid: string): AsyncResult<void>;
@@ -0,0 +1,56 @@
1
+ import type { AsyncResult } from "../../../types/src/result";
2
+ import type { Exec } from "../exec";
3
+ import type { Store } from "../store";
4
+ import type { InventoryDevice, Owner, Platform } from "../types";
5
+ import type { Timer } from "./health";
6
+ /**
7
+ * A device backend (iOS simulators, Android emulators). Providers report raw devices with
8
+ * `wardenCreated: false`; callers mark warden's own via `markWardenDevices` (store `devices` table).
9
+ * Providers never decide ownership — they only list, create, boot, wait and shut down.
10
+ */
11
+ export interface DeviceProvider {
12
+ readonly platform: Platform;
13
+ inventory(): AsyncResult<InventoryDevice[]>;
14
+ /** Create (iOS) or launch (Android) a new warden device called `name`. Returned device is not necessarily ready. */
15
+ create(name: string, profile: string, runtime?: string): AsyncResult<InventoryDevice>;
16
+ boot(id: string): AsyncResult<void>;
17
+ waitReady(id: string, timeoutMs: number): AsyncResult<void>;
18
+ shutdown(id: string): AsyncResult<void>;
19
+ /** Is a booted device responsive? Absent = no probe (Android), treated as healthy. */
20
+ probe?(id: string): AsyncResult<void>;
21
+ /**
22
+ * Cheap proof that a device already Booted when claimed is usable: when it succeeds the claim skips
23
+ * `waitReady` and `probe`. A failure is not a verdict — the claim falls back to `waitReady` + `probe`.
24
+ * Absent = always take the full path.
25
+ */
26
+ verifyWarm?(id: string): AsyncResult<void>;
27
+ /**
28
+ * Boot a shut-down device and wait until it is ready in one step, where the platform can do both at
29
+ * once (iOS: `bootstatus -b` boots it). Absent = `boot`, then `waitReady`.
30
+ */
31
+ bootReady?(id: string, timeoutMs: number): Promise<BootReady>;
32
+ /** Probe for a device `bootReady` just booted and saw ready. Absent = `probe`. */
33
+ probeBooted?(id: string): AsyncResult<void>;
34
+ }
35
+ /** `bootReady`: a boot failure is an error; a device that booted but never got ready is unhealthy. */
36
+ export type BootReady = {
37
+ kind: "ready";
38
+ } | {
39
+ kind: "boot-failed";
40
+ error: string;
41
+ } | {
42
+ kind: "not-ready";
43
+ error: string;
44
+ };
45
+ export type ProviderDeps = {
46
+ exec: Exec;
47
+ store: Store;
48
+ env: Record<string, string | undefined>;
49
+ now: () => number;
50
+ /** the claimer — owner for any side leases a provider takes (e.g. Android console ports) */
51
+ owner: Owner;
52
+ /** timer for probe timeouts (default: real timers) */
53
+ timer?: (ms: number) => Timer;
54
+ /** progress notes (e.g. "cloning from golden…") — stderr in the CLI */
55
+ log?: (line: string) => void;
56
+ };
@@ -0,0 +1,105 @@
1
+ import { Database } from "bun:sqlite";
2
+ import { type PidAlive } from "./liveness";
3
+ import type { Lease, Owner, Platform, QueueEntry, Resource } from "./types";
4
+ /** `$WARDEN_HOME`, else `~/.warden`. */
5
+ export declare function wardenHome(env?: Record<string, string | undefined>): string;
6
+ export declare function defaultDbPath(env?: Record<string, string | undefined>): string;
7
+ /**
8
+ * Ordered schema migrations; index + 1 = `PRAGMA user_version` after applying. Append only — never
9
+ * edit an entry that has shipped.
10
+ */
11
+ export declare const MIGRATIONS: readonly string[];
12
+ /** A queued claim that has not refreshed within this long is dead (its waiter polls every few seconds). */
13
+ export declare const QUEUE_TTL_MS = 60000;
14
+ export declare function ownerKey(owner: Owner): string;
15
+ export type NewLease = {
16
+ resource: Resource;
17
+ owner: Owner;
18
+ ttlMs: number;
19
+ label?: string;
20
+ pid?: number;
21
+ bootedByOwner?: boolean;
22
+ shared?: boolean;
23
+ };
24
+ /** A device warden created (sim) or launched (emulator). */
25
+ export type DeviceRecord = {
26
+ platform: Platform;
27
+ id: string;
28
+ name: string;
29
+ profile?: string;
30
+ runtime?: string;
31
+ createdAt: number;
32
+ lastUsedAt: number;
33
+ /** set when the device was reported bad (`release --bad`, failed health probe): never allocated until cleared */
34
+ quarantinedAt?: number;
35
+ quarantineReason?: string;
36
+ /** set while the simulator's daemons are switched off (`sim slim`); cleared by `--restore` */
37
+ slimmedAt?: number;
38
+ };
39
+ export declare function newLeaseId(): string;
40
+ /**
41
+ * The lease registry. One sqlite file (WAL) shared by every warden process on the machine;
42
+ * `transaction` runs under `BEGIN IMMEDIATE`, which is the cross-process mutex for claims.
43
+ */
44
+ export declare class Store {
45
+ readonly db: Database;
46
+ constructor(db: Database);
47
+ close(): void;
48
+ /** Run `fn` under `BEGIN IMMEDIATE` (write lock taken up front). Nested calls join the outer txn. */
49
+ transaction<T>(fn: () => T): T;
50
+ insertLease(input: NewLease, now: number): Lease;
51
+ /** Mark leases shared (`Lease.shared`): `warden dev` on the device leases it just claimed. */
52
+ markShared(ids: readonly string[]): void;
53
+ /** Record that this lease's owner booted its device (claim boot/create, hook saw it shut down). */
54
+ markBootedByOwner(id: string): void;
55
+ /** Record the process a lease now lives with (an emulator launched after its port was leased). */
56
+ setLeasePid(id: string, pid: number): void;
57
+ getMeta(key: string): string | undefined;
58
+ setMeta(key: string, value: string): void;
59
+ deleteMeta(key: string): void;
60
+ getLease(id: string): Lease | undefined;
61
+ listLeases(): Lease[];
62
+ listLeasesByOwner(owner: Owner): Lease[];
63
+ findLeaseByResource(resource: Resource): Lease | undefined;
64
+ deleteLeases(ids: readonly string[]): number;
65
+ /**
66
+ * Stamp `heartbeat_at = now` on `ids`; returns how many leases were updated (fewer than `ids` = some
67
+ * were released). `periodMs` (the holder's heartbeat interval) marks them supervised: with a pid,
68
+ * a dead pid makes them stale after `LOST_HEARTBEATS` periods instead of the TTL.
69
+ */
70
+ heartbeat(ids: readonly string[], now: number, periodMs?: number): number;
71
+ /** Delete leases that are not alive (`isLeaseAlive`: pid dead AND heartbeat expired). Returns reclaimed ids. */
72
+ reclaimStale(now: number, pidAlive: PidAlive): string[];
73
+ /** Insert a queue row; an existing id keeps its `enqueuedAt` (queue age) and updates the rest. */
74
+ enqueue(entry: QueueEntry): QueueEntry;
75
+ /** Stamp `heartbeat_at`; false when the row is gone (reclaimed or dequeued). */
76
+ refreshQueue(id: string, now: number): boolean;
77
+ dequeue(id: string): boolean;
78
+ /** Raw insertion order (`enqueued_at`, id); use `queueOrder` for admission order. */
79
+ listQueue(): QueueEntry[];
80
+ /** Delete queue rows whose waiter is gone: pid dead, or heartbeat older than `QUEUE_TTL_MS`. */
81
+ reclaimStaleQueue(now: number, pidAlive: PidAlive): string[];
82
+ recordDevice(device: Omit<DeviceRecord, "createdAt" | "lastUsedAt">, now: number): void;
83
+ listDevices(platform?: Platform): DeviceRecord[];
84
+ touchDevice(platform: Platform, id: string, now: number): void;
85
+ /** Quarantine a warden device (recording it first when only its name marked it ours). */
86
+ quarantineDevice(device: Omit<DeviceRecord, "createdAt" | "lastUsedAt" | "quarantinedAt" | "quarantineReason" | "slimmedAt">, reason: string | undefined, now: number): void;
87
+ /** Clear quarantine on every recorded device with this id (any platform); returns how many were cleared. */
88
+ unquarantineDevice(id: string): number;
89
+ /** Record (`at` set) or clear (`at` undefined) that a recorded device's daemons are slimmed. */
90
+ setSlimmed(platform: Platform, id: string, at: number | undefined): void;
91
+ forgetDevice(platform: Platform, id: string): void;
92
+ /**
93
+ * What a `warden gc` pass could find to do, from the store alone (no simulator is asked):
94
+ * - `staleLeases`: leases whose heartbeat expired and whose process is gone;
95
+ * - `idleDevices`: recorded devices nobody holds that went idle (unused for `idleMs`) after the
96
+ * last pass (`last_gc_at`). Whether one is still booted only the provider knows; a pass that
97
+ * ran after the device went idle has dealt with it, so it is not work again until it is used.
98
+ */
99
+ gcWork(now: number, idleMs: number, pidAlive: PidAlive): {
100
+ staleLeases: number;
101
+ idleDevices: number;
102
+ };
103
+ }
104
+ /** Open (creating + migrating) the store at `path`, default `$WARDEN_HOME/warden.db`. */
105
+ export declare function openStore(path?: string): Store;