@deeeed/metamask-harness 0.83.0 → 0.85.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/CHANGELOG.md +62 -0
- package/README.md +8 -1
- package/adapters/extension/console-tail.mjs +11 -7
- package/adapters/extension/ensure-browser.sh +17 -18
- package/adapters/manifest.json +14 -110
- package/adapters/mobile/start-console-forwarder.sh +8 -0
- package/adapters/shared/resolve-slot-ports-core.mjs +48 -6
- package/adapters/shared/session-lock.mjs +43 -0
- package/adapters/web-dapp/metamask-signer.mjs +212 -0
- package/dist/adapters/extension/actions.js +5 -0
- package/dist/adapters/extension/console-capture.js +18 -0
- package/dist/adapters/extension/fixtures.js +2 -0
- package/dist/adapters/extension/overlay.js +5 -4
- package/dist/adapters/extension/run.js +4 -2
- package/dist/adapters/extension/runtime.js +73 -18
- package/dist/adapters/mobile/devices.js +5 -1
- package/dist/adapters/mobile/launch.js +19 -2
- package/dist/adapters/mobile/provision.js +4 -2
- package/dist/adapters/mobile/run.js +3 -2
- package/dist/adapters/mobile/surface.js +1 -2
- package/dist/adapters/mobile/verify.js +15 -7
- package/dist/adapters/plugin-surface.js +14 -42
- package/dist/adapters/session-lock.js +23 -0
- package/dist/adapters/web-dapp/actions.js +7 -5
- package/dist/adapters/web-dapp/console-capture.js +2 -20
- package/dist/adapters/web-dapp/run.js +25 -24
- package/dist/adapters/web-dapp/surface.js +32 -108
- package/dist/adapters.js +5 -3
- package/dist/command-contract.js +1351 -643
- package/dist/commands/check.js +5 -7
- package/dist/commands/debug.js +2 -2
- package/dist/commands/doctor.js +3 -4
- package/dist/commands/domain.js +4 -3
- package/dist/commands/fixtures.js +3 -3
- package/dist/commands/help.js +6 -5
- package/dist/commands/launch/device.js +1 -1
- package/dist/commands/launch/extension.js +9 -7
- package/dist/commands/logs.js +2 -2
- package/dist/commands/prepare.js +4 -4
- package/dist/commands/provision.js +2 -2
- package/dist/commands/review.js +8 -10
- package/dist/commands/runtime-launch.js +2 -0
- package/dist/commands/status.js +4 -2
- package/dist/commands/task-init.js +2 -3
- package/dist/console-classifier.js +3 -2
- package/dist/doctor.js +11 -4
- package/dist/mm-harness-cli.js +241 -1210
- package/dist/paths.js +9 -4
- package/dist/recipe-security.js +1 -1
- package/dist/review/knowledge.js +10 -2
- package/dist/runner.js +18 -3
- package/docs/CONTRIBUTING.md +14 -4
- package/docs/RECIPES.md +14 -1
- package/docs/cli-contract.md +8 -7
- package/docs/feature-guard.md +1 -1
- package/library/actions/extension/app/assert_no_console_errors.mjs +95 -0
- package/library/actions/extension/platform/cdp.mjs +26 -0
- package/library/actions/mobile/app/assert_no_console_errors.mjs +129 -0
- package/library/actions/shared/console-assert.mjs +174 -0
- package/library/actions/shared/console-collector.mjs +11 -4
- package/library/actions/shared/console-findings.mjs +108 -7
- package/library/actions/web-dapp/app/assert_no_console_errors.mjs +10 -54
- package/library/actions/web-dapp/app/launch.mjs +1 -1
- package/library/actions/web-dapp/platform/adapter-web-lib.mjs +18 -0
- package/library/actions/web-dapp/platform/hud.mjs +3 -10
- package/library/actions/web-dapp/platform/page.mjs +63 -18
- package/library/actions/web-dapp/platform/runtime.mjs +30 -179
- package/library/diagnostics/console-allowlist.json +13 -0
- package/library/manifests/extension.action-manifest.json +36 -0
- package/library/manifests/mobile.action-manifest.json +36 -0
- package/library/manifests/web-dapp.action-manifest.json +1 -1
- package/library/recipes/core/health/check.recipe.json +40 -0
- package/library/recipes/extension/health/check.recipe.json +71 -0
- package/library/recipes/mobile/health/check.recipe.json +71 -0
- package/package.json +9 -9
- package/scripts/completions.sh +1 -1
- package/site/assets/help-recipes.json +2 -1
- package/adapters/web-dapp/cleanup.mjs +0 -69
- package/adapters/web-dapp/inject.mjs +0 -75
- package/adapters/web-dapp/launch.mjs +0 -631
- package/adapters/web-dapp/lib/blocked-hosts.mjs +0 -24
- package/adapters/web-dapp/lib/cdp-client.mjs +0 -84
- package/adapters/web-dapp/lib/display.mjs +0 -115
- package/adapters/web-dapp/lib/hud.mjs +0 -148
- package/adapters/web-dapp/lib/policy-fence.mjs +0 -119
- package/adapters/web-dapp/lib/policy.mjs +0 -84
- package/adapters/web-dapp/lib/processes.mjs +0 -187
- package/adapters/web-dapp/lib/readiness.mjs +0 -272
- package/adapters/web-dapp/stop.mjs +0 -38
- package/adapters/web-dapp/verify.mjs +0 -51
- package/adapters/web-dapp/wallet-host.mjs +0 -520
- package/dist/adapters/web-dapp/doctor-checks.js +0 -33
- package/dist/adapters/web-dapp/launch.js +0 -87
- package/dist/adapters/web-dapp/overlay.js +0 -21
- package/dist/adapters/web-dapp/readiness.js +0 -10
- package/dist/adapters/web-dapp/stop.js +0 -20
- package/dist/cli.js +0 -202
package/dist/paths.js
CHANGED
|
@@ -17,12 +17,12 @@ function recipeWatchLogCandidates() {
|
|
|
17
17
|
path.join(recipeRuntimeDir(), "recipe-harness-webpack.log")
|
|
18
18
|
];
|
|
19
19
|
}
|
|
20
|
-
function resolveLocalProtocolRoot() {
|
|
21
|
-
const root =
|
|
20
|
+
function resolveLocalProtocolRoot(env = process.env) {
|
|
21
|
+
const root = env.METAMASK_RUNNER_PROTOCOL_ROOT || readConfiguredProtocolRoot();
|
|
22
22
|
return root ? path.resolve(root) : void 0;
|
|
23
23
|
}
|
|
24
|
-
function resolveFarmslotPoolDir(starts = [runnerDir, process.cwd()]) {
|
|
25
|
-
const configured = resolveLocalProtocolRoot();
|
|
24
|
+
function resolveFarmslotPoolDir(starts = [runnerDir, process.cwd()], env = process.env) {
|
|
25
|
+
const configured = resolveLocalProtocolRoot(env);
|
|
26
26
|
if (configured) return path.join(configured, "pool");
|
|
27
27
|
for (const start of starts) {
|
|
28
28
|
const pool = findPoolDir(start);
|
|
@@ -30,6 +30,10 @@ function resolveFarmslotPoolDir(starts = [runnerDir, process.cwd()]) {
|
|
|
30
30
|
}
|
|
31
31
|
return void 0;
|
|
32
32
|
}
|
|
33
|
+
function harnessSlotPoolDir(env = process.env, starts) {
|
|
34
|
+
if (env.FARMSLOT_POOL_DIR || env.FARMSLOT_ROOT) return void 0;
|
|
35
|
+
return resolveFarmslotPoolDir(starts, env);
|
|
36
|
+
}
|
|
33
37
|
const LIBRARY_ACTION_NAMESPACE = "metamask";
|
|
34
38
|
function readConfiguredProtocolRoot() {
|
|
35
39
|
const configPath = path.join(runnerDir, ".farmslot-root");
|
|
@@ -156,6 +160,7 @@ export {
|
|
|
156
160
|
PREPARE_PROGRESS_ARTIFACT,
|
|
157
161
|
assertAdapter,
|
|
158
162
|
extensionIdPath,
|
|
163
|
+
harnessSlotPoolDir,
|
|
159
164
|
importMetaMaskNativeUiProvider,
|
|
160
165
|
importRecipeHarness,
|
|
161
166
|
importRecipeHarnessAppLifecycle,
|
package/dist/recipe-security.js
CHANGED
|
@@ -32,7 +32,7 @@ const READ_ONLY_CUSTOM_ACTIONS = /* @__PURE__ */ new Set([
|
|
|
32
32
|
// Terminal wallet request log reads.
|
|
33
33
|
"metamask.wallet.read_signatures",
|
|
34
34
|
"metamask.wallet.assert_signatures",
|
|
35
|
-
//
|
|
35
|
+
// Console capture read (web-dapp, Mobile, Extension).
|
|
36
36
|
"metamask.app.assert_no_console_errors"
|
|
37
37
|
]);
|
|
38
38
|
const APP_MUTATION_CUSTOM_ACTIONS = /* @__PURE__ */ new Set([
|
package/dist/review/knowledge.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { detectAdapter, usageError } from "@farmslot/recipe-cli/harness";
|
|
1
|
+
import { AdapterAmbiguousError, detectAdapter, usageError } from "@farmslot/recipe-cli/harness";
|
|
2
2
|
import { execFileSync } from "node:child_process";
|
|
3
3
|
import fs from "node:fs";
|
|
4
4
|
import os from "node:os";
|
|
@@ -234,7 +234,7 @@ function resolveReference(adapter, context) {
|
|
|
234
234
|
const fromConfig = context.config?.references?.[adapter];
|
|
235
235
|
if (fromConfig && isDir(path.resolve(fromConfig))) return { adapter, root: path.resolve(fromConfig), source: "config" };
|
|
236
236
|
const target = path.resolve(context.target);
|
|
237
|
-
const siblings = siblingRoots(context).filter((root) => root !== target &&
|
|
237
|
+
const siblings = siblingRoots(context).filter((root) => root !== target && siblingAdapter(root) === adapter).sort((a, b) => {
|
|
238
238
|
const refA = path.basename(a).endsWith("-ref") ? 0 : 1;
|
|
239
239
|
const refB = path.basename(b).endsWith("-ref") ? 0 : 1;
|
|
240
240
|
return refA - refB || path.basename(a).length - path.basename(b).length || a.localeCompare(b);
|
|
@@ -242,6 +242,14 @@ function resolveReference(adapter, context) {
|
|
|
242
242
|
if (siblings[0]) return { adapter, root: siblings[0], source: "sibling" };
|
|
243
243
|
return void 0;
|
|
244
244
|
}
|
|
245
|
+
function siblingAdapter(root) {
|
|
246
|
+
try {
|
|
247
|
+
return detectAdapter(root);
|
|
248
|
+
} catch (error) {
|
|
249
|
+
if (error instanceof AdapterAmbiguousError) return void 0;
|
|
250
|
+
throw error;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
245
253
|
function referenceHint(adapter) {
|
|
246
254
|
return `set ${referenceEnvName(adapter)} or run: mm-harness config set references.${adapter} <path>`;
|
|
247
255
|
}
|
package/dist/runner.js
CHANGED
|
@@ -78,7 +78,7 @@ async function createMetaMaskRunner(adapter, actionManifest, options = {}) {
|
|
|
78
78
|
const { createAgentDeviceUiTransport } = await importMetaMaskNativeUiProvider();
|
|
79
79
|
return createAgentDeviceUiTransport(options2);
|
|
80
80
|
}
|
|
81
|
-
}, preparedLiveAdapters
|
|
81
|
+
}, preparedLiveAdapters)
|
|
82
82
|
});
|
|
83
83
|
const existing = new Set([...core, ...ui].map((entry) => entry.action));
|
|
84
84
|
const lifecycleAdapters = bindMetaMaskMobileLifecycleDeadline(
|
|
@@ -97,7 +97,8 @@ async function createMetaMaskRunner(adapter, actionManifest, options = {}) {
|
|
|
97
97
|
].filter(
|
|
98
98
|
(entry) => declaredActions.has(entry.action) && !existing.has(entry.action) && !lifecycle.some((lifecycleEntry) => lifecycleEntry.action === entry.action)
|
|
99
99
|
);
|
|
100
|
-
const
|
|
100
|
+
const hudOffSwitch = process.env.METAMASK_RECIPE_AUTO_HUD === "0" || process.env.METAMASK_RECIPE_AUTO_HUD === "false";
|
|
101
|
+
const autoHudDisabled = options.autoHud === false || options.autoHud === void 0 && hudOffSwitch;
|
|
101
102
|
const externalMutationActions = new Set(
|
|
102
103
|
Object.entries(actionManifest.actions).filter(([, declaration]) => Array.isArray(declaration.execution_capabilities) && declaration.execution_capabilities.includes("external-mutation")).map(([action]) => action)
|
|
103
104
|
);
|
|
@@ -111,7 +112,7 @@ async function createMetaMaskRunner(adapter, actionManifest, options = {}) {
|
|
|
111
112
|
...adapterSecurity(entry.action, actionSources, options.trustTaskActions)
|
|
112
113
|
}));
|
|
113
114
|
const mutationBound = surface.actions.trustedMutation ? withTrustedMutationContext(secured, options.trustedMutation, externalMutationActions) : secured;
|
|
114
|
-
|
|
115
|
+
const runner = createRecipeRunner({
|
|
115
116
|
actionManifest,
|
|
116
117
|
adapters: withActionProgress(
|
|
117
118
|
surface.actions.wrap ? await surface.actions.wrap(mutationBound) : mutationBound,
|
|
@@ -139,6 +140,20 @@ async function createMetaMaskRunner(adapter, actionManifest, options = {}) {
|
|
|
139
140
|
}
|
|
140
141
|
}
|
|
141
142
|
});
|
|
143
|
+
const clearHud = surface.actions.clearHud;
|
|
144
|
+
if (options.autoHud !== false || hudOffSwitch || !clearHud) return runner;
|
|
145
|
+
return {
|
|
146
|
+
preflight: (request) => runner.preflight(request),
|
|
147
|
+
async run(request) {
|
|
148
|
+
if (request.projectRoot) {
|
|
149
|
+
await clearHud(request.projectRoot).catch((error) => {
|
|
150
|
+
process.stderr.write(`WARN: could not clear the HUD a previous run left (${error instanceof Error ? error.message : String(error)}); this run's screenshots may show it.
|
|
151
|
+
`);
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
return runner.run(request);
|
|
155
|
+
}
|
|
156
|
+
};
|
|
142
157
|
}
|
|
143
158
|
function withTrustedMutationContext(adapters, base, externalMutationActions) {
|
|
144
159
|
let selectedIdentitySha256;
|
package/docs/CONTRIBUTING.md
CHANGED
|
@@ -67,9 +67,11 @@ in the registry, so a platform registered the same way is reached by the
|
|
|
67
67
|
commands that take `--adapter` with no edit to shared code
|
|
68
68
|
(`tests/fixtures/example-adapter.ts`); `status` detects the adapter from the
|
|
69
69
|
checkout.
|
|
70
|
-
The
|
|
71
|
-
|
|
72
|
-
|
|
70
|
+
The public command table (`src/command-contract.ts`) reads the `--adapter`
|
|
71
|
+
choices when a command is validated: the registered adapters, then the adapters
|
|
72
|
+
the libraries on `RECIPE_LIBRARY_PATH` and the command's `--library` entries
|
|
73
|
+
declare. A new platform or library plugin reaches `bin/mm-harness` with no edit
|
|
74
|
+
to the table.
|
|
73
75
|
|
|
74
76
|
Extend `AdapterSurface` when a platform owns the behavior; members a platform
|
|
75
77
|
does not need are optional, and Core reports headless/N/A explicitly. Put each
|
|
@@ -82,7 +84,7 @@ command-local platform ladders for surface-owned behavior:
|
|
|
82
84
|
- `run`/`call` come from `@farmslot/recipe-cli/harness` and read `run`,
|
|
83
85
|
`recording`, `diagnostics`, `observation` and `sourceFingerprint`;
|
|
84
86
|
mm-harness hands them its engine (`src/engine.ts`) and its device targeting
|
|
85
|
-
and plan steps (`src/cli.ts`);
|
|
87
|
+
and plan steps (`src/mm-harness-cli.ts`);
|
|
86
88
|
- the runner reads `actions` (manifest, bundled semantic actions, live adapter
|
|
87
89
|
scripts, `cdp.target`, the `ui.*` transport, adapter wrappers, input checks);
|
|
88
90
|
- `install`/`verify`/`cleanup` read `harness`, `agentic-runtime.json` reads
|
|
@@ -177,6 +179,14 @@ yarn check
|
|
|
177
179
|
bash tests/contract/run.sh
|
|
178
180
|
```
|
|
179
181
|
|
|
182
|
+
`run.sh` runs half the cores' worth of tests at once (`-j N` to change it), each
|
|
183
|
+
in its own process group and `TMPDIR`, and prints each test's duration and the
|
|
184
|
+
15 slowest. A test gets 60 s (`CONTRACT_TEST_TIMEOUT`); one whose product waits
|
|
185
|
+
are longer declares `# contract: timeout=<seconds>`, and one that cannot share
|
|
186
|
+
the machine (it rebuilds `dist/`) declares `# contract: serial` and runs after
|
|
187
|
+
the others. Whatever a test leaves running is killed and named on a `LEAK` line.
|
|
188
|
+
An unfiltered run over 3 minutes (`CONTRACT_BUDGET`) fails.
|
|
189
|
+
|
|
180
190
|
Runtime changes also require real affected-platform QA and a packed installation
|
|
181
191
|
test. Update `CHANGELOG.md` under `Unreleased` for user-visible behavior.
|
|
182
192
|
|
package/docs/RECIPES.md
CHANGED
|
@@ -19,6 +19,18 @@ mm-harness run <name> --describe
|
|
|
19
19
|
Discovery reports parameters, selected adapter variant, nested recipes, source,
|
|
20
20
|
and shadows. Use `--json` for exact metadata.
|
|
21
21
|
|
|
22
|
+
Before ticket work, run `mm-harness run health.check`. It is read-only and
|
|
23
|
+
picks the variant for the current adapter. Mobile and Extension check that the
|
|
24
|
+
app answers, onboarding is done, the wallet is unlocked on a selected account
|
|
25
|
+
at wallet home, and the app logged no console error since launch outside the
|
|
26
|
+
console allowlists, then take one screenshot. Core reads one HyperLiquid
|
|
27
|
+
testnet account through the Perps controller and requires a numeric balance.
|
|
28
|
+
A failure stops at a node named after the error (`APP_NOT_RUNNING`,
|
|
29
|
+
`ONBOARDING`, `WALLET_LOCKED`, `NO_SELECTED_ACCOUNT`, `NOT_ON_HOME`,
|
|
30
|
+
`CONSOLE_ERRORS`, `CONTROLLER_UNAVAILABLE`, `ACCOUNT_READ_FAILED`), and that
|
|
31
|
+
node's intent gives the recovery command. A reachable bridge does not prove
|
|
32
|
+
the iOS app is in the foreground; check the screenshot.
|
|
33
|
+
|
|
22
34
|
## Optional recipe advisor
|
|
23
35
|
|
|
24
36
|
`recipe-quality plan` assesses acceptance criteria against adapter-supported
|
|
@@ -419,7 +431,8 @@ mm-harness run perps.performance \
|
|
|
419
431
|
--heal off --record-video=full-run
|
|
420
432
|
```
|
|
421
433
|
|
|
422
|
-
The
|
|
434
|
+
The recipe HUD is shown by default on every adapter (Mobile, Extension,
|
|
435
|
+
web-dapp/Terminal, headless browsers included), including during performance
|
|
423
436
|
validation. Use `--hud hide` only when a specific opaque/release proof requires
|
|
424
437
|
an unobstructed product surface; do not hide it merely to collect timings.
|
|
425
438
|
|
package/docs/cli-contract.md
CHANGED
|
@@ -94,21 +94,21 @@ These are real farm bugs. The goldens freeze the current rejections, so fixing e
|
|
|
94
94
|
|
|
95
95
|
`goldens-terminal` freezes the `va-mmcx-terminal-farm` hooks with their exact argv (`recipe_action_manifest`, `recipe_doctor`, `recipe_harness_{install,verify,cleanup}`, `recipe_run`, the browser `boot`/`shutdown` resources and `teardown`), against a fresh fixture slot: no slot dev server, no Extension dist, and the farm's wallet fixture outside the slot, so `install` copies it in. `launch` is frozen on its cold path: it refuses at the missing dist before any browser starts. The slot's CDP port is the one port no port holder reserves: a farm slot with no browser has it free, and the Terminal's port checks probe it.
|
|
96
96
|
|
|
97
|
-
`terminal` is the plugin the `experimental-metamask-recipe-terminal` library declares (`adapters.terminal`, extends the built-in `web-dapp`), so the family loads it: set `GOLDENS_TERMINAL_LIBRARY` to a checkout of that library
|
|
97
|
+
`terminal` is the plugin the `experimental-metamask-recipe-terminal` library declares (`adapters.terminal`, extends the built-in `web-dapp`), so the family loads it: set `GOLDENS_TERMINAL_LIBRARY` to a checkout of that library. The family copies one pinned commit of it (`GOLDENS_TERMINAL_LIBRARY_REV` in `goldens-terminal.test.sh`) into its sandbox with `git archive` and puts it on `RECIPE_LIBRARY_PATH`, so the goldens don't move when the checkout does. The checkout must contain that commit (`git fetch`); moving the pin and regenerating the goldens is one change. CI has no access to the private library and skips the family (`CI` set); local runs need the variable, and without it outside CI the family fails. A release gate must set it too (proposed for the release checklist). `goldens-discovery` covers the built-in `web-dapp` in its per-adapter loop.
|
|
98
98
|
|
|
99
99
|
Outside this boundary: a successful `launch` and the `temp/recipe/runtime/terminal/` layout (`browser.pid`), the library's recipes, `run terminal.perps.<name> --plan`, and the recipe-terminal `console-guard.mjs` import paths.
|
|
100
100
|
|
|
101
101
|
## Running the goldens
|
|
102
102
|
|
|
103
103
|
```bash
|
|
104
|
-
yarn test:goldens # compare all families in parallel (CI runs them in the contract shards)
|
|
104
|
+
yarn test:goldens # compare all families in parallel, within 2 minutes (CI runs them in the contract shards)
|
|
105
105
|
yarn test:goldens mobile # families whose name contains "mobile"
|
|
106
106
|
yarn test:goldens --update # rewrite goldens, print a per-field diff summary, drop orphans
|
|
107
107
|
```
|
|
108
108
|
|
|
109
|
-
Every family checks its own golden directory: a golden no case writes fails the family, in CI too (`--update` deletes it). Every family also fails on a golden directory that has no `goldens-<dir>.test.sh`, so deleting a whole family script is caught. Families run
|
|
109
|
+
Every family checks its own golden directory: a golden no case writes fails the family, in CI too (`--update` deletes it). Every family also fails on a golden directory that has no `goldens-<dir>.test.sh`, so deleting a whole family script is caught. Families run through `tests/contract/run.sh`, locally and inside the contract CI shards, as many at once as `-j` allows (half the cores by default).
|
|
110
110
|
|
|
111
|
-
Each case runs under a safety cap, `GOLDENS_CASE_TIMEOUT` (default
|
|
111
|
+
Families run through `tests/contract/run.sh`, so a family that hangs is stopped at its time limit, and the run fails over `GOLDENS_BUDGET` (default 120 s). Each case runs under a safety cap, `GOLDENS_CASE_TIMEOUT` (default 60 s). A case that hits it reports `TIMEOUT <case>` and fails without being compared or written, so `--update` can never accept a truncated capture.
|
|
112
112
|
|
|
113
113
|
Each family is a normal contract test (`tests/contract/goldens-<family>.test.sh`). `gd_init` (`tests/contract/goldens/lib.sh`) builds a sandbox on `ct_init`, and every case runs under `env -i`:
|
|
114
114
|
- `gd_init` first unsets every inherited `MM_HARNESS_*`, `RECIPE_*`, `METAMASK_*` and `FARMSLOT_*` variable, plus device and port pins (`IOS_SIMULATOR`, `ADB_SERIAL`, `WATCHER_PORT`, `METRO_PORT`, `CDP_PORT`, `IDB_PATH`, `ANDROID_HOME`, …). A developer's `MM_HARNESS_BIN` or `RECIPE_RN_IDB_PATH` export therefore cannot redirect a case;
|
|
@@ -116,12 +116,13 @@ Each family is a normal contract test (`tests/contract/goldens-<family>.test.sh`
|
|
|
116
116
|
- every interpreter and tool the goldens launch is resolved to its real binary before `HOME` moves into the sandbox: `node` (`process.execPath`), `git` (`git --exec-path`), `bash` (`$BASH`) and `timeout`. The goldens need no Python. A tool that resolves to a script, the shape of an asdf/mise/volta/pyenv shim, fails the family up front with a message naming it. Each is checked to run with a sandboxed `HOME`;
|
|
117
117
|
- `PATH` is pinned to the case stubs, the sandbox stubs, those real binaries, and `/usr/bin:/bin:/usr/sbin:/sbin`. Host tools (`idb`, `capture-helper`, a developer's Chrome) cannot change a golden;
|
|
118
118
|
- device-tool discovery is pinned: `RECIPE_RN_IDB_PATH` and `RECIPE_RN_ADB_PATH` point at sandbox stubs, which discovery checks before any absolute Homebrew or pipx path;
|
|
119
|
-
- localhost probes cannot reach a real server. `gd_reserve_ports` runs a Node port holder (`tests/contract/goldens/port-holder.mjs`) that holds ephemeral ports on `127.0.0.1` and `::1` and resets every connection it accepts, so Metro `/status` and CDP probes never get a response and nothing real can take the port mid-run. Each family exports the farm's slot port variables (`RECIPE_CDP_PORT`, `WATCHER_PORT`, `METRO_PORT`) set to those ports, so commands without an explicit port use them too. Goldens show them as `<PORT:NAME>`, rewritten only in port positions (`*port` keys, `:<port>`, `port <n>`/`PORT=<n>`, or a string that is exactly the port), so an unrelated equal number such as a 60000 ms timeout is never rewritten;
|
|
119
|
+
- localhost probes cannot reach a real server. `gd_reserve_ports` runs a Node port holder (`tests/contract/goldens/port-holder.mjs`) that holds ephemeral ports on `127.0.0.1` and `::1` and resets every connection it accepts, so Metro `/status` and CDP probes never get a response and nothing real can take the port mid-run. Each family exports the farm's slot port variables (`RECIPE_CDP_PORT`, `WATCHER_PORT`, `METRO_PORT`) set to those ports, so commands without an explicit port use them too. Goldens show them as `<PORT:NAME>`, rewritten only in port positions (`*port` keys, numbers under a `ports` object, `:<port>`, `port <n>`/`PORT=<n>`, or a string that is exactly the port), so an unrelated equal number such as a 60000 ms timeout is never rewritten;
|
|
120
120
|
- `tmux`, `xcrun` and `adb` are stubbed (`ct_init`, plus one booted simulator and one Android device for Mobile);
|
|
121
121
|
- forbidden stubs record any call to `open`, `osascript`, `lsappinfo`, `screencapture`, `yarn`, `npx`, `npm`, `corepack`, `gh` and Chrome/Chromium, and the family fails if one is called. A browser stub answers only `--version`, and `npm` only `root -g` (install-source detection), with a sandbox path;
|
|
122
122
|
- inert stubs on the case PATH only: `lsof`, `pgrep` and `pkill` find nothing, `ps` passes per-pid queries (`-p`, the CLI identifying its own processes) and lists an empty host for table listings, and `watchman` and `caffeinate` are no-ops. `stop` cannot see or signal a real process or touch a host daemon;
|
|
123
123
|
- `RECIPE_HARNESS_BROWSER` points at the sandbox Chrome stub, so browser resolution never probe-launches Chrome for Testing or reads host Chrome policy;
|
|
124
|
-
- `MM_HARNESS_MOBILE_VERIFY_PROBE_TIMEOUT_MS=2000` shortens Mobile verify's live-bridge wait (default 60 s), because no fixture provides a debug target
|
|
124
|
+
- `MM_HARNESS_MOBILE_VERIFY_PROBE_TIMEOUT_MS=2000` shortens Mobile verify's live-bridge wait (default 60 s), because no fixture provides a debug target;
|
|
125
|
+
- `goldens-mobile-launch` points `METAMASK_RECIPE_MOBILE_BRIDGE_SCRIPT` at a bridge that answers "no app is running" at once (`ct_no_mobile_app`): launch clears remote feature-flag overrides through the bridge, and the real one waits out its 10 s CDP timeout when no app runs.
|
|
125
126
|
|
|
126
127
|
Mobile Metro, device, bridge and wallet leaves, the Extension build/browser leaf, and the fixture-state leaf are stubbed through `MM_HARNESS_SCRIPT_BIN_*`. `leaf-calls.log` and `live-calls.log` are frozen per launch case.
|
|
127
128
|
|
|
@@ -140,7 +141,7 @@ The goldens need the harness to be a git checkout: `execution-provenance.json` `
|
|
|
140
141
|
|
|
141
142
|
`tests/contract/goldens/normalize.mjs` is the only normaliser. It rewrites only volatile values:
|
|
142
143
|
|
|
143
|
-
1. Absolute paths: the sandbox becomes `<SANDBOX>`, the repo `<REPO>`, the real `$HOME` `<HOME>`, the node binary and its prefix `<NODE>`/`<NODE_PREFIX>`, and `os.tmpdir()` `<TMP>`. Both `/var` and `/private/var` spellings are covered. Ports from `gd_reserve_ports` become `<PORT:NAME>` in port positions only: numbers under `*port` keys, and in text after `:`, `port `, `port=` or `PORT=`, or a string that is exactly the port (`^<port>$`; `60000 ms` is left alone).
|
|
144
|
+
1. Absolute paths: the sandbox becomes `<SANDBOX>`, the repo `<REPO>`, the real `$HOME` `<HOME>`, the node binary and its prefix `<NODE>`/`<NODE_PREFIX>`, and `os.tmpdir()` `<TMP>`. Both `/var` and `/private/var` spellings are covered. Ports from `gd_reserve_ports` become `<PORT:NAME>` in port positions only: numbers under `*port` keys or a `ports` object (the context's `ports.cdp.value`), and in text after `:`, `port `, `port=` or `PORT=`, or a string that is exactly the port (`^<port>$`; `60000 ms` is left alone).
|
|
144
145
|
2. UUIDs become `<UUID>`. Git object ids (40 hex, or a short hex value under a `*head`/`*gitRef`/`*commit` key) become `<GIT_REF>`. ISO-8601 timestamps become `<TIMESTAMP>`, and compact stamps in names `<STAMP>`. Path-derived slot ids `local-<adapter>-<8 hex>` become `local-<adapter>-<HASH>`.
|
|
145
146
|
3. The package version becomes `<HARNESS_VERSION>`. Any other `x.y.z` under a key ending in `version` becomes `<VERSION>`.
|
|
146
147
|
4. Numbers under volatile keys: `*pid` becomes `<PID>`, `inode` `<INODE>`, `*port` `<PORT>`, `*At`/`*Time`/`*timestamp`/`mtime*` `<TIMESTAMP>`, and `*Ms`/`duration*`/`elapsed*` `<DURATION>`. Values inside `schema` and `examples` subtrees (static manifest content) are never rewritten.
|
package/docs/feature-guard.md
CHANGED
|
@@ -74,7 +74,7 @@ Captures are normalized (paths, versions, pids, timestamps, durations, epoch num
|
|
|
74
74
|
|
|
75
75
|
What the normaliser deliberately masks, and so cannot catch: any semver under a key ending in `version` other than the harness's own, numbers under keys ending in `Ms`/`Seconds`/`duration`/`elapsed` (so a changed timeout value), epoch-millisecond numbers, and the `volatile` paths (a new product warning during a live run). Each varies run to run on real slots; the goldens freeze the static values.
|
|
76
76
|
|
|
77
|
-
Machine rules: on macwork, headless only and no focus stealing (`TERMINAL_HEADLESS=1` is in the matrix).
|
|
77
|
+
Machine rules: on macwork, headless only and no focus stealing (`TERMINAL_HEADLESS=1` is in the matrix). A headful `run`/`call` (Extension, including its wallet popup, or a headful web-dapp) can't proceed while macwork's screen is locked: it refuses with `SESSION_LOCKED` at its runtime check (it used to stall until a `Page.captureScreenshot` timeout). Mobile and the Extension run on free macpro or mini slots, never mini-mm-3. The differential stops and cleans the slot runtime it used; reinstall the farm's overlay afterwards if the slot is handed back to the farm.
|
|
78
78
|
|
|
79
79
|
## 3. Release smoke
|
|
80
80
|
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Fail the recipe when MetaMask (its service worker or UI pages) logged an
|
|
3
|
+
// error or uncaught exception that no console allowlist marks as known noise.
|
|
4
|
+
// Reads extension-console.log, written by the slot's console collector
|
|
5
|
+
// (adapters/extension/console-tail.mjs). The same classifier and allowlist
|
|
6
|
+
// feed run diagnostics.
|
|
7
|
+
//
|
|
8
|
+
// Window: since "launch" (the default) judges the events since the launch
|
|
9
|
+
// began, by their own timestamps. The harness records each launch's start
|
|
10
|
+
// before it (re)starts the browser (extension-console.launch.json,
|
|
11
|
+
// written by src/adapters/extension/console-capture.ts): the log's offset
|
|
12
|
+
// and inode, and the time. The collector appends across launches, so
|
|
13
|
+
// the log is read from that offset; and a collector replaced on a launch that
|
|
14
|
+
// keeps the browser replays the browser's console from before it, so a record
|
|
15
|
+
// stamped (with the event's own CDP timestamp) before the launch time is left
|
|
16
|
+
// out too. Without a mark the action fails instead of reading earlier
|
|
17
|
+
// sessions. since "start-of-log" reads the whole file.
|
|
18
|
+
//
|
|
19
|
+
// It never passes on missing evidence: this runtime's collector must be
|
|
20
|
+
// running on the run's CDP port, and a control line logged in MetaMask's
|
|
21
|
+
// service worker or page must reach extension-console.log; otherwise the
|
|
22
|
+
// action fails as unavailable.
|
|
23
|
+
|
|
24
|
+
import { readFileSync } from 'node:fs';
|
|
25
|
+
import { pathToFileURL } from 'node:url';
|
|
26
|
+
|
|
27
|
+
import { recipeRuntimePath } from '@farmslot/recipe-cli/harness';
|
|
28
|
+
|
|
29
|
+
import { captureUnavailable, consoleLevels, inWindow, judgeConsole, nodeAllowlist, readFrom, readLaunchMark, reportVerdict, throwOnFindings } from '../../shared/console-assert.mjs';
|
|
30
|
+
import { collectorOwnerMarker, collectorStatus, verifyCapture } from '../../shared/console-collector.mjs';
|
|
31
|
+
import { runAdapter } from '../platform/cdp.mjs';
|
|
32
|
+
|
|
33
|
+
const LOG = 'extension-console.log';
|
|
34
|
+
const LAUNCH_MARK = 'extension-console.launch.json';
|
|
35
|
+
const unavailable = (detail) => captureUnavailable(detail, 'The MetaMask extension console');
|
|
36
|
+
|
|
37
|
+
// A file's content, or null when it does not exist.
|
|
38
|
+
function readOptional(file) {
|
|
39
|
+
try {
|
|
40
|
+
return readFileSync(file, 'utf8');
|
|
41
|
+
} catch (error) {
|
|
42
|
+
if (error?.code === 'ENOENT') return null;
|
|
43
|
+
throw error;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function assertNoConsoleErrors(input, env = process.env, deps = {}) {
|
|
48
|
+
const node = input.node ?? {};
|
|
49
|
+
const projectRoot = input.context?.projectRoot;
|
|
50
|
+
const levels = consoleLevels(node);
|
|
51
|
+
const log = recipeRuntimePath(projectRoot, LOG);
|
|
52
|
+
// The CDP port as every Extension live action resolves it.
|
|
53
|
+
const cdpPort = node.cdp_port ?? env.CDP_PORT ?? env.RECIPE_CDP_PORT;
|
|
54
|
+
if (!cdpPort) throw unavailable('no CDP port (node.cdp_port, CDP_PORT or RECIPE_CDP_PORT)');
|
|
55
|
+
const capture = (deps.collectorStatus ?? collectorStatus)({
|
|
56
|
+
pidFile: recipeRuntimePath(projectRoot, 'console-tail.pid'),
|
|
57
|
+
extensionLog: log,
|
|
58
|
+
marker: collectorOwnerMarker(recipeRuntimePath(projectRoot)),
|
|
59
|
+
cdpPort: String(cdpPort),
|
|
60
|
+
});
|
|
61
|
+
if (!capture.ok) throw unavailable(capture.detail);
|
|
62
|
+
const extensionId = readOptional(recipeRuntimePath(projectRoot, 'extension.id'))?.trim() || null;
|
|
63
|
+
const control = await (deps.verifyCapture ?? verifyCapture)({
|
|
64
|
+
cdpPort,
|
|
65
|
+
log,
|
|
66
|
+
target: 'extension',
|
|
67
|
+
extensionId,
|
|
68
|
+
...(node.timeout_ms ? { timeoutMs: Number(node.timeout_ms) } : {}),
|
|
69
|
+
});
|
|
70
|
+
if (!control.ok) throw unavailable(control.detail);
|
|
71
|
+
const mark = node.since === 'start-of-log' ? null : readLaunchMark(recipeRuntimePath(projectRoot, LAUNCH_MARK), unavailable);
|
|
72
|
+
const { text, reset } = readFrom(log, mark ?? { offset: 0 });
|
|
73
|
+
const allowlist = nodeAllowlist(node, env);
|
|
74
|
+
const keep = inWindow({ sinceMs: mark?.launchedAtMs ?? null, now: deps.now ?? Date.now() });
|
|
75
|
+
const { findings, allowlisted } = judgeConsole([text], { levels, allowlist, keep });
|
|
76
|
+
const output = {
|
|
77
|
+
action: input.action,
|
|
78
|
+
since: node.since ?? 'launch',
|
|
79
|
+
levels: [...levels],
|
|
80
|
+
findings,
|
|
81
|
+
allowlisted,
|
|
82
|
+
allowlistProblems: allowlist.problems,
|
|
83
|
+
capture: { collector: capture.detail, control: control.detail },
|
|
84
|
+
// The launch mark was past the end of a replaced (rotated) capture
|
|
85
|
+
// file; that file was read from its start.
|
|
86
|
+
offsetReset: reset,
|
|
87
|
+
proofPath: 'extension-console-capture',
|
|
88
|
+
};
|
|
89
|
+
throwOnFindings(findings, allowlist.problems);
|
|
90
|
+
return output;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
94
|
+
reportVerdict(() => runAdapter((input) => assertNoConsoleErrors(input)));
|
|
95
|
+
}
|
|
@@ -76,6 +76,32 @@ export function extensionTarget(targets, extensionId = null, cdpTargetId = null)
|
|
|
76
76
|
return home ?? sidepanel ?? null;
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
+
// The recipe-runner HUD (app.hud's clear) removed from every extension page on
|
|
80
|
+
// the slot's CDP port. Launches nothing; each step is bounded and a missing
|
|
81
|
+
// port or browser clears nothing.
|
|
82
|
+
const CLEAR_HUD_EXPRESSION = "(() => { const hud = document.getElementById('farmslot-recipe-hud'); hud?.remove(); document.getElementById('farmslot-recipe-hud-reserved-space')?.remove(); document.documentElement.style.removeProperty('--farmslot-recipe-hud-height'); document.body?.style.removeProperty('padding-bottom'); return Boolean(hud); })()";
|
|
83
|
+
|
|
84
|
+
export async function clearExtensionHud(env = process.env, timeoutMs = 2000) {
|
|
85
|
+
const port = Number(env.CDP_PORT ?? env.RECIPE_CDP_PORT);
|
|
86
|
+
if (!Number.isInteger(port) || port <= 0) return { cleared: 0 };
|
|
87
|
+
const response = await fetch(`http://127.0.0.1:${port}/json/list`, { signal: AbortSignal.timeout(timeoutMs) }).catch(() => null);
|
|
88
|
+
const targets = response?.ok ? await response.json().catch(() => []) : [];
|
|
89
|
+
let cleared = 0;
|
|
90
|
+
for (const target of Array.isArray(targets) ? targets : []) {
|
|
91
|
+
if (target?.type !== 'page' || !String(target.url ?? '').startsWith('chrome-extension://') || !target.webSocketDebuggerUrl) continue;
|
|
92
|
+
let session;
|
|
93
|
+
try {
|
|
94
|
+
session = await CdpSession.connect(target.webSocketDebuggerUrl, { timeoutMs });
|
|
95
|
+
const result = await session.call('Runtime.evaluate', { expression: CLEAR_HUD_EXPRESSION, returnByValue: true }, { timeoutMs });
|
|
96
|
+
if (result?.result?.value === true) cleared += 1;
|
|
97
|
+
} catch {
|
|
98
|
+
// A busy or closing page keeps nothing worth failing the run for.
|
|
99
|
+
} finally {
|
|
100
|
+
session?.close();
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return { cleared };
|
|
104
|
+
}
|
|
79
105
|
|
|
80
106
|
function resolveRelativeArtifactPath(artifactsDir, relPath) {
|
|
81
107
|
const relative = relPath || 'screenshots/extension-page.png';
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Fail the recipe when the app logged an error or uncaught exception that no
|
|
3
|
+
// console allowlist marks as known noise. Reads app-console.log, written by the
|
|
4
|
+
// adapter-rn console forwarder (adapters/mobile/start-console-forwarder.sh).
|
|
5
|
+
// The same classifier and allowlist feed run diagnostics.
|
|
6
|
+
//
|
|
7
|
+
// Window: since "launch" (the default) judges the events since the launch
|
|
8
|
+
// began, by their own timestamps. A launch records its start once its
|
|
9
|
+
// refusals are behind it and before anything can restart the app
|
|
10
|
+
// (app-console.launch.json, recordMobileConsoleLaunch in
|
|
11
|
+
// src/adapters/mobile/launch.ts); a recovery attempt that restarts the app
|
|
12
|
+
// records it again. Stamps come from the device's clock, so the window is
|
|
13
|
+
// moved onto it by the offset the capture control line shows. Every launch
|
|
14
|
+
// restarts the forwarder, and start-console-forwarder.sh empties
|
|
15
|
+
// app-console.log and the forwarder's replay cursor as it does, so a
|
|
16
|
+
// relaunched app's first lines are kept; but on a launch that keeps the app
|
|
17
|
+
// runtime, the new forwarder replays that runtime's console from before the
|
|
18
|
+
// launch. The forwarder stamps each line with the event's own runtime time,
|
|
19
|
+
// so a record stamped before the launch is left out. Without a mark the
|
|
20
|
+
// action fails as unavailable. since "start-of-log" reads the whole file.
|
|
21
|
+
//
|
|
22
|
+
// Device: app-console.log holds every device the forwarder sees; only the
|
|
23
|
+
// device the control line came back from (the one the bridge selects for this
|
|
24
|
+
// run) is judged. When that line names no device, every device is, and the
|
|
25
|
+
// output says so (devices: "all").
|
|
26
|
+
//
|
|
27
|
+
// It never passes on missing evidence: the forwarder named by
|
|
28
|
+
// console-forwarder.pid must be alive and writing this checkout's
|
|
29
|
+
// app-console.log, and a control line logged inside the app through the debug
|
|
30
|
+
// bridge must reach that file; otherwise the action fails as unavailable. The
|
|
31
|
+
// opaque runtime (an installed release build) has no bridge, so it always
|
|
32
|
+
// fails as unavailable.
|
|
33
|
+
|
|
34
|
+
import { randomUUID } from 'node:crypto';
|
|
35
|
+
import { existsSync, realpathSync } from 'node:fs';
|
|
36
|
+
import path from 'node:path';
|
|
37
|
+
import { pathToFileURL } from 'node:url';
|
|
38
|
+
|
|
39
|
+
import { recipeRuntimeDir } from '@farmslot/recipe-cli/harness';
|
|
40
|
+
|
|
41
|
+
import { captureUnavailable, consoleLevels, inWindow, nearestInstant, judgeConsole, nodeAllowlist, readFrom, readLaunchMark, reportVerdict, throwOnFindings } from '../../shared/console-assert.mjs';
|
|
42
|
+
import { CAPTURE_CONTROL_MARK, controlLineReached, fileSize, processCommand, readPid } from '../../shared/console-collector.mjs';
|
|
43
|
+
import { consoleRecords, recordStamp } from '../../shared/console-findings.mjs';
|
|
44
|
+
import { evalSync, runAdapter } from '../platform/bridge.mjs';
|
|
45
|
+
|
|
46
|
+
const LOG = 'app-console.log';
|
|
47
|
+
const LAUNCH_MARK = 'app-console.launch.json';
|
|
48
|
+
const unavailable = (detail) => captureUnavailable(detail, 'The app console');
|
|
49
|
+
|
|
50
|
+
// The forwarder start-console-forwarder.sh started for this checkout: a live
|
|
51
|
+
// process running console-forwarder.cjs whose --out is this runtime's
|
|
52
|
+
// app-console.log, as the script writes it (the checkout's resolved path).
|
|
53
|
+
export function forwarderStatus({ pidFile, log }) {
|
|
54
|
+
const pid = readPid(pidFile);
|
|
55
|
+
if (!pid) return { ok: false, detail: `no console forwarder pid in ${pidFile}` };
|
|
56
|
+
const command = processCommand(pid);
|
|
57
|
+
if (!command) return { ok: false, detail: `console forwarder pid ${pid} is not running` };
|
|
58
|
+
if (!/\/console-forwarder\.cjs --port \S+ --out /u.test(command) || !command.endsWith(` --out ${log}`)) {
|
|
59
|
+
return { ok: false, detail: `pid ${pid} is not this checkout's console forwarder` };
|
|
60
|
+
}
|
|
61
|
+
return { ok: true, detail: `console forwarder pid ${pid}` };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Log a control line inside the app; the forwarder writes it at info level,
|
|
65
|
+
// and the classifier never reports a capture control line anyway.
|
|
66
|
+
function logInApp(input, line) {
|
|
67
|
+
return evalSync(input, `(console.info(${JSON.stringify(line)}), true)`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function assertNoConsoleErrors(input, env = process.env, deps = {}) {
|
|
71
|
+
const node = input.node ?? {};
|
|
72
|
+
const levels = consoleLevels(node);
|
|
73
|
+
if (env.METAMASK_RECIPE_MOBILE_OPAQUE_RUNTIME === '1') {
|
|
74
|
+
throw unavailable('the opaque runtime has no debug bridge to log a control line through');
|
|
75
|
+
}
|
|
76
|
+
const projectRoot = input.context?.projectRoot;
|
|
77
|
+
if (!projectRoot || !existsSync(projectRoot)) throw unavailable(`no checkout at ${projectRoot}`);
|
|
78
|
+
const runtime = path.join(realpathSync(projectRoot), recipeRuntimeDir());
|
|
79
|
+
const log = path.join(runtime, LOG);
|
|
80
|
+
const forwarder = (deps.forwarderStatus ?? forwarderStatus)({ pidFile: path.join(runtime, 'console-forwarder.pid'), log });
|
|
81
|
+
if (!forwarder.ok) throw unavailable(forwarder.detail);
|
|
82
|
+
const mark = node.since === 'start-of-log' ? null : readLaunchMark(path.join(runtime, LAUNCH_MARK), unavailable);
|
|
83
|
+
const nonce = randomUUID();
|
|
84
|
+
const start = fileSize(log);
|
|
85
|
+
const clock = deps.clock ?? (() => deps.now ?? Date.now());
|
|
86
|
+
const before = clock();
|
|
87
|
+
try {
|
|
88
|
+
await (deps.logInApp ?? logInApp)(input, `${CAPTURE_CONTROL_MARK} ${nonce}`);
|
|
89
|
+
} catch (error) {
|
|
90
|
+
// No line logged: the capture is unproven, which is this action's failure.
|
|
91
|
+
throw unavailable(`could not log the capture control line in the app (${error.message})`);
|
|
92
|
+
}
|
|
93
|
+
const after = clock();
|
|
94
|
+
const timeoutMs = Number(node.timeout_ms) > 0 ? Number(node.timeout_ms) : 8000;
|
|
95
|
+
if (!await controlLineReached(log, start, nonce, timeoutMs)) {
|
|
96
|
+
throw unavailable(`the capture control line logged in the app did not reach ${log} within ${timeoutMs}ms`);
|
|
97
|
+
}
|
|
98
|
+
const { text } = readFrom(log, { offset: 0 });
|
|
99
|
+
// The control line names the device, and its stamp, from the device's clock,
|
|
100
|
+
// bounds that clock's offset from this machine's (a physical device's clock
|
|
101
|
+
// can run minutes off; the simulator shares the host's). The line was
|
|
102
|
+
// logged between `before` and `after` on the host, so bridge latency only
|
|
103
|
+
// widens the bound; it never moves the launch past a fresh record.
|
|
104
|
+
const control = consoleRecords(text).find((record) => record.line.includes(nonce));
|
|
105
|
+
const controlStamp = control ? recordStamp(control) : null;
|
|
106
|
+
const device = controlStamp?.device ?? null;
|
|
107
|
+
const controlAt = controlStamp ? nearestInstant(controlStamp.clock, before) : null;
|
|
108
|
+
const offset = controlAt === null ? { min: 0, max: 0 } : { min: controlAt - after, max: controlAt - before };
|
|
109
|
+
const allowlist = nodeAllowlist(node, env);
|
|
110
|
+
const keep = inWindow({ sinceMs: mark?.launchedAtMs ?? null, device, now: clock(), offset });
|
|
111
|
+
const { findings, allowlisted } = judgeConsole([text], { levels, allowlist, keep });
|
|
112
|
+
const output = {
|
|
113
|
+
action: input.action,
|
|
114
|
+
since: node.since ?? 'launch',
|
|
115
|
+
devices: device ?? 'all',
|
|
116
|
+
levels: [...levels],
|
|
117
|
+
findings,
|
|
118
|
+
allowlisted,
|
|
119
|
+
allowlistProblems: allowlist.problems,
|
|
120
|
+
capture: { forwarder: forwarder.detail, control: `control line from the app reached ${LOG}` },
|
|
121
|
+
proofPath: 'mobile-console-capture',
|
|
122
|
+
};
|
|
123
|
+
throwOnFindings(findings, allowlist.problems);
|
|
124
|
+
return output;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
128
|
+
reportVerdict(() => runAdapter((input) => assertNoConsoleErrors(input)));
|
|
129
|
+
}
|