@deeeed/metamask-harness 0.58.0 → 0.60.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 +21 -0
- package/README.md +48 -0
- package/adapters/extension/build-handoff.cjs +4 -0
- package/adapters/extension/inject.mjs +0 -1
- package/adapters/extension/live.sh +26 -25
- package/adapters/extension/pin-remote-flags.cjs +69 -30
- package/adapters/manifest.json +4 -4
- package/adapters/mobile/bridge-runtime/cdp-bridge.cjs +16 -0
- package/adapters/mobile/bridge-runtime/setup-wallet.sh +69 -2
- package/adapters/shared/resolve-slot-ports-core.mjs +47 -19
- package/dist/adapters/extension/feature-flags.js +41 -0
- package/dist/adapters/extension/runtime-decision.js +2 -2
- package/dist/adapters/extension/runtime.js +4 -3
- package/dist/adapters/extension/verify.js +608 -0
- package/dist/adapters/mobile/feature-flags.js +127 -0
- package/dist/adapters/resolve-slot-ports.js +2 -0
- package/dist/adapters.js +6 -1
- package/dist/cli.js +1 -1
- package/dist/command-contract.js +6 -3
- package/dist/commands/doctor.js +9 -1
- package/dist/commands/fixtures.js +45 -3
- package/dist/commands/launch/extension.js +28 -6
- package/dist/commands/launch/index.js +128 -17
- package/dist/commands/launch/mobile.js +26 -2
- package/dist/commands/mobile-device-view.js +8 -1
- package/dist/commands/prepare.js +20 -3
- package/dist/commands/run-engine.js +10 -1
- package/dist/commands/runtime-launch.js +10 -1
- package/dist/commands/status-probe.js +14 -1
- package/dist/commands/status.js +13 -0
- package/dist/doctor.js +9 -1
- package/dist/feature-flags.js +193 -0
- package/dist/harness.js +3 -15
- package/dist/mm-harness-cli.js +15 -2
- package/library/actions/core/feature_flags/_unsupported.mjs +29 -0
- package/library/actions/core/feature_flags/clear.mjs +8 -0
- package/library/actions/core/feature_flags/read.mjs +8 -0
- package/library/actions/core/feature_flags/set.mjs +8 -0
- package/library/actions/extension/feature_flags/clear.mjs +48 -0
- package/library/actions/extension/feature_flags/read.mjs +27 -0
- package/library/actions/extension/feature_flags/set.mjs +36 -0
- package/library/actions/extension/feature_flags/state.mjs +129 -0
- package/library/actions/harness-exports.mjs +14 -0
- package/library/actions/mobile/feature_flags/clear.mjs +20 -0
- package/library/actions/mobile/feature_flags/controller.mjs +120 -0
- package/library/actions/mobile/feature_flags/read.mjs +20 -0
- package/library/actions/mobile/feature_flags/set.mjs +25 -0
- package/library/actions/mobile/wallet/reset.mjs +22 -1
- package/library/manifests/core.action-manifest.json +90 -0
- package/library/manifests/extension.action-manifest.json +70 -0
- package/library/manifests/mobile.action-manifest.json +90 -0
- package/package.json +1 -1
- package/adapters/extension/verify.sh +0 -572
|
@@ -3,7 +3,7 @@ import fs from "node:fs";
|
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { color, colorHumanMessage } from "../../cli-color.js";
|
|
5
5
|
import { handleHarness } from "../../harness.js";
|
|
6
|
-
import { recipeRuntimePath, runnerDir } from "../../paths.js";
|
|
6
|
+
import { recipeRuntimePath, runnerDir, walletFixturePath } from "../../paths.js";
|
|
7
7
|
import { getAdapterSurface } from "../../adapters/surface.js";
|
|
8
8
|
import {
|
|
9
9
|
ADAPTER_DETECT_NEXT,
|
|
@@ -35,6 +35,15 @@ import { launchMobile } from "./mobile.js";
|
|
|
35
35
|
import { applyDeviceTargeting } from "../device-target.js";
|
|
36
36
|
import { acquireCheckoutLock } from "../../checkout-lock.js";
|
|
37
37
|
import { JsonStreamWriter } from "../../json-stream.js";
|
|
38
|
+
import {
|
|
39
|
+
FeatureFlagSpecError,
|
|
40
|
+
fixtureFeatureFlags,
|
|
41
|
+
parseRemoteFlagSpec,
|
|
42
|
+
remoteFlagRequestFor,
|
|
43
|
+
sameFeatureFlags,
|
|
44
|
+
toRemoteFlagSpec
|
|
45
|
+
} from "../../feature-flags.js";
|
|
46
|
+
import { readExtensionPinnedFlags } from "../../adapters/extension/feature-flags.js";
|
|
38
47
|
import { ensureExtensionConsoleCapture } from "../../adapters/extension/console-capture.js";
|
|
39
48
|
import { extensionProductConfigBlock } from "../../adapters/extension/product-config.js";
|
|
40
49
|
import {
|
|
@@ -178,6 +187,25 @@ async function handleLaunchLocked(argv, stream) {
|
|
|
178
187
|
);
|
|
179
188
|
}
|
|
180
189
|
const displayMode = requestedSurface ?? legacySurface ?? "fullscreen";
|
|
190
|
+
const remoteFlagSpec = str(options, "remoteFlag");
|
|
191
|
+
if (remoteFlagSpec !== void 0 && adapter === "extension" && wantWatch) {
|
|
192
|
+
return launchUsage(
|
|
193
|
+
jsonOutput,
|
|
194
|
+
stream,
|
|
195
|
+
"--remote-flag cannot be applied with --watch: the watcher never re-creates the dist snapshot that carries the pin.",
|
|
196
|
+
`mm-harness launch --adapter extension --remote-flag ${remoteFlagSpec} --target ${shellQuote(target)}`
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
let remoteFlagRequest;
|
|
200
|
+
try {
|
|
201
|
+
remoteFlagRequest = remoteFlagRequestFor({
|
|
202
|
+
...fixtureFeatureFlags(walletFixturePath(target)),
|
|
203
|
+
...remoteFlagSpec === void 0 ? {} : parseRemoteFlagSpec(remoteFlagSpec)
|
|
204
|
+
});
|
|
205
|
+
} catch (error) {
|
|
206
|
+
if (!(error instanceof FeatureFlagSpecError)) throw error;
|
|
207
|
+
return launchUsage(jsonOutput, stream, error.message, error.userAction);
|
|
208
|
+
}
|
|
181
209
|
for (const portFlag of ["cdpPort", "watcherPort"]) {
|
|
182
210
|
const value = str(options, portFlag);
|
|
183
211
|
if (value !== void 0 && !/^\d+$/u.test(value)) {
|
|
@@ -286,10 +314,23 @@ async function handleLaunchLocked(argv, stream) {
|
|
|
286
314
|
machine,
|
|
287
315
|
displayMode,
|
|
288
316
|
false,
|
|
289
|
-
wantClearMetro
|
|
317
|
+
wantClearMetro,
|
|
318
|
+
remoteFlagRequest
|
|
290
319
|
);
|
|
291
320
|
if (attempt.status === 0) {
|
|
292
|
-
return await
|
|
321
|
+
return await settleLaunch(jsonOutput, machine, stream, adapter, mobileTarget, tier, displayMode, target, state, wantWatch, wantVerify, remoteFlagRequest, true, attempt.remoteFeatureFlags);
|
|
322
|
+
}
|
|
323
|
+
if (adapter === "extension" && attempt.output.includes("REMOTE_FLAG_NOT_PINNABLE:")) {
|
|
324
|
+
return launchFail(jsonOutput, stream, adapter, mobileTarget, tier, state, target, {
|
|
325
|
+
code: "REMOTE_FLAG_NOT_PINNABLE",
|
|
326
|
+
message: `a verified release artifact cannot carry a remote feature-flag pin (${remoteFlagRequest?.spec ?? ""}); its manifest is signed evidence and is never rewritten. Validate the artifact as shipped (drop --remote-flag and the fixture's remoteFeatureFlags) or validate the variant on a source build.`,
|
|
327
|
+
recoverable: false,
|
|
328
|
+
// The next step must leave the artifact alone: a --build here would replace
|
|
329
|
+
// the artifact under validation with a source build.
|
|
330
|
+
userAction: Object.keys(fixtureFeatureFlags(walletFixturePath(target))).length > 0 ? `remove remoteFeatureFlags from ${shellQuote(walletFixturePath(target))}, then: mm-harness launch --adapter extension --target ${shellQuote(target)}` : `mm-harness launch --adapter extension --target ${shellQuote(target)}`,
|
|
331
|
+
exitCode: EXIT.infra,
|
|
332
|
+
originalError: attempt.output.trim() || void 0
|
|
333
|
+
});
|
|
293
334
|
}
|
|
294
335
|
if (adapter === "extension" && extensionProductConfigMissing(attempt.output)) {
|
|
295
336
|
const block = extensionProductConfigBlock(target);
|
|
@@ -352,10 +393,10 @@ async function handleLaunchLocked(argv, stream) {
|
|
|
352
393
|
const recoveryCode2 = "chrome.relaunched";
|
|
353
394
|
state.attemptedRecoveries.push(recoveryCode2);
|
|
354
395
|
stream.phase("recover");
|
|
355
|
-
const rebuildAttempt = await executeComposition(adapter, mobileTarget, "build", wantWatch, target, machine, displayMode);
|
|
396
|
+
const rebuildAttempt = await executeComposition(adapter, mobileTarget, "build", wantWatch, target, machine, displayMode, false, false, remoteFlagRequest);
|
|
356
397
|
if (rebuildAttempt.status === 0) {
|
|
357
398
|
state.recovered.push(recoveryCode2);
|
|
358
|
-
return await
|
|
399
|
+
return await settleLaunch(jsonOutput, machine, stream, adapter, mobileTarget, tier, displayMode, target, state, wantWatch, wantVerify, remoteFlagRequest, true, rebuildAttempt.remoteFeatureFlags);
|
|
359
400
|
}
|
|
360
401
|
return launchFail(jsonOutput, stream, adapter, mobileTarget, tier, state, target, {
|
|
361
402
|
code: "EXTENSION_RELAUNCH_FAILED",
|
|
@@ -434,11 +475,13 @@ async function handleLaunchLocked(argv, stream) {
|
|
|
434
475
|
target,
|
|
435
476
|
machine,
|
|
436
477
|
displayMode,
|
|
437
|
-
restartMobileApp
|
|
478
|
+
restartMobileApp,
|
|
479
|
+
false,
|
|
480
|
+
remoteFlagRequest
|
|
438
481
|
);
|
|
439
482
|
if (attempt.status === 0) {
|
|
440
483
|
state.recovered.push(recoveryCode);
|
|
441
|
-
return await
|
|
484
|
+
return await settleLaunch(jsonOutput, machine, stream, adapter, mobileTarget, tier, displayMode, target, state, wantWatch, wantVerify, remoteFlagRequest, true, attempt.remoteFeatureFlags);
|
|
442
485
|
}
|
|
443
486
|
return launchFail(jsonOutput, stream, adapter, mobileTarget, tier, state, target, {
|
|
444
487
|
code: "SAME_RECOVERY_TWICE",
|
|
@@ -560,7 +603,39 @@ function nativeInputsChanged(target, adapter) {
|
|
|
560
603
|
return false;
|
|
561
604
|
}
|
|
562
605
|
}
|
|
563
|
-
|
|
606
|
+
function verifyExtensionPin(target, requested) {
|
|
607
|
+
const pinned = readExtensionPinnedFlags(target);
|
|
608
|
+
if (pinned.error) {
|
|
609
|
+
return {
|
|
610
|
+
ok: false,
|
|
611
|
+
flags: {},
|
|
612
|
+
code: "RUNTIME_MANIFEST_UNREADABLE",
|
|
613
|
+
message: `the launched dist snapshot manifest could not be read (${pinned.sourcePath ?? "unknown path"}): ${pinned.error}`
|
|
614
|
+
};
|
|
615
|
+
}
|
|
616
|
+
if (pinned.overrides !== null && pinned.pinned === null) {
|
|
617
|
+
return {
|
|
618
|
+
ok: false,
|
|
619
|
+
flags: {},
|
|
620
|
+
code: "REMOTE_FLAG_NOT_APPLIED",
|
|
621
|
+
message: `the launched dist snapshot manifest (${pinned.sourcePath ?? "unknown path"}) carries no harness pin record; it was not written by this launch.`
|
|
622
|
+
};
|
|
623
|
+
}
|
|
624
|
+
const actual = pinned.pinned ?? {};
|
|
625
|
+
if (!sameFeatureFlags(actual, requested)) {
|
|
626
|
+
const mismatched = [.../* @__PURE__ */ new Set([...Object.keys(requested), ...Object.keys(actual)])].filter(
|
|
627
|
+
(key) => JSON.stringify(actual[key]) !== JSON.stringify(requested[key])
|
|
628
|
+
);
|
|
629
|
+
return {
|
|
630
|
+
ok: false,
|
|
631
|
+
flags: {},
|
|
632
|
+
code: "REMOTE_FLAG_NOT_APPLIED",
|
|
633
|
+
message: `the remote feature-flag pin ${toRemoteFlagSpec(requested) || "(none)"} does not match the launched snapshot manifest: ` + mismatched.map((key) => `${key} expected ${JSON.stringify(requested[key])}, got ${JSON.stringify(actual[key])}`).join("; ")
|
|
634
|
+
};
|
|
635
|
+
}
|
|
636
|
+
return { ok: true, flags: actual, message: "" };
|
|
637
|
+
}
|
|
638
|
+
async function executeComposition(adapter, mobileTarget, tier, wantWatch, target, json, displayMode = "fullscreen", restartMobileApp = false, clearMobileMetro = false, remoteFlagRequest) {
|
|
564
639
|
if (adapter === "mobile") {
|
|
565
640
|
return launchMobile(
|
|
566
641
|
target,
|
|
@@ -568,12 +643,44 @@ async function executeComposition(adapter, mobileTarget, tier, wantWatch, target
|
|
|
568
643
|
tier,
|
|
569
644
|
json,
|
|
570
645
|
restartMobileApp,
|
|
571
|
-
clearMobileMetro
|
|
646
|
+
clearMobileMetro,
|
|
647
|
+
remoteFlagRequest?.flags
|
|
572
648
|
);
|
|
573
649
|
}
|
|
574
|
-
return launchExtension(target, tier, wantWatch, displayMode);
|
|
650
|
+
return launchExtension(target, tier, wantWatch, displayMode, remoteFlagRequest);
|
|
575
651
|
}
|
|
576
|
-
async function
|
|
652
|
+
async function settleLaunch(jsonOutput, machine, stream, adapter, mobileTarget, tier, displayMode, target, state, wantWatch, wantVerify, remoteFlagRequest, sidepanelRecoveryAllowed = true, mobileProvenFlags) {
|
|
653
|
+
const requested = remoteFlagRequest?.flags ?? {};
|
|
654
|
+
const pinned = adapter !== "extension" ? { ok: true, flags: mobileProvenFlags === void 0 ? requested : mobileProvenFlags, message: "" } : wantWatch ? { ok: true, flags: readExtensionPinnedFlags(target).pinned, message: "" } : verifyExtensionPin(target, requested);
|
|
655
|
+
if (!pinned.ok) {
|
|
656
|
+
const spec = remoteFlagRequest?.spec;
|
|
657
|
+
return launchFail(jsonOutput, stream, adapter, mobileTarget, tier, state, target, {
|
|
658
|
+
code: pinned.code ?? "REMOTE_FLAG_NOT_APPLIED",
|
|
659
|
+
message: pinned.message,
|
|
660
|
+
recoverable: false,
|
|
661
|
+
// A rebuild re-creates the snapshot; the flag is repeated only when one was asked for.
|
|
662
|
+
userAction: `mm-harness launch --adapter extension --build${spec ? ` --remote-flag ${spec}` : ""} --target ${shellQuote(target)}`,
|
|
663
|
+
exitCode: EXIT.runtime
|
|
664
|
+
});
|
|
665
|
+
}
|
|
666
|
+
return finishLaunch(
|
|
667
|
+
jsonOutput,
|
|
668
|
+
machine,
|
|
669
|
+
stream,
|
|
670
|
+
adapter,
|
|
671
|
+
mobileTarget,
|
|
672
|
+
tier,
|
|
673
|
+
displayMode,
|
|
674
|
+
target,
|
|
675
|
+
state,
|
|
676
|
+
wantWatch,
|
|
677
|
+
wantVerify,
|
|
678
|
+
sidepanelRecoveryAllowed,
|
|
679
|
+
remoteFlagRequest,
|
|
680
|
+
pinned.flags
|
|
681
|
+
);
|
|
682
|
+
}
|
|
683
|
+
async function finishLaunch(jsonOutput, machine, stream, adapter, mobileTarget, tier, displayMode, target, state, wantWatch, wantVerify, sidepanelRecoveryAllowed = true, remoteFlagRequest, remoteFlags = {}) {
|
|
577
684
|
if (adapter === "extension" && displayMode === "fullscreen") {
|
|
578
685
|
const sidepanelSh = path.join(runnerDir, "adapters/extension/sidepanel-toggle.sh");
|
|
579
686
|
const sidepanelArgs = ["close"];
|
|
@@ -605,10 +712,10 @@ async function finishLaunch(jsonOutput, machine, stream, adapter, mobileTarget,
|
|
|
605
712
|
if (sidepanelRecoveryAllowed && tier === "quick" && extensionRuntimeBlocked(sidepanel.output) && !state.attemptedRecoveries.includes(recoveryCode)) {
|
|
606
713
|
state.attemptedRecoveries.push(recoveryCode);
|
|
607
714
|
stream.phase("recover");
|
|
608
|
-
const rebuildAttempt = await executeComposition(adapter, mobileTarget, "build", wantWatch, target, machine, displayMode);
|
|
715
|
+
const rebuildAttempt = await executeComposition(adapter, mobileTarget, "build", wantWatch, target, machine, displayMode, false, false, remoteFlagRequest);
|
|
609
716
|
if (rebuildAttempt.status === 0) {
|
|
610
717
|
state.recovered.push(recoveryCode);
|
|
611
|
-
return await
|
|
718
|
+
return await settleLaunch(
|
|
612
719
|
jsonOutput,
|
|
613
720
|
machine,
|
|
614
721
|
stream,
|
|
@@ -620,6 +727,7 @@ async function finishLaunch(jsonOutput, machine, stream, adapter, mobileTarget,
|
|
|
620
727
|
state,
|
|
621
728
|
wantWatch,
|
|
622
729
|
wantVerify,
|
|
730
|
+
remoteFlagRequest,
|
|
623
731
|
false
|
|
624
732
|
);
|
|
625
733
|
}
|
|
@@ -668,14 +776,15 @@ async function finishLaunch(jsonOutput, machine, stream, adapter, mobileTarget,
|
|
|
668
776
|
tier,
|
|
669
777
|
recovered: state.recovered,
|
|
670
778
|
mutations: state.mutations,
|
|
671
|
-
metro: metroLaunchEvidence(adapter, target)
|
|
779
|
+
metro: metroLaunchEvidence(adapter, target),
|
|
780
|
+
remoteFeatureFlags: remoteFlags
|
|
672
781
|
});
|
|
673
782
|
}
|
|
674
783
|
return exitCode;
|
|
675
784
|
}
|
|
676
|
-
return launchPass(jsonOutput, stream, adapter, mobileTarget, tier, displayMode, target, state);
|
|
785
|
+
return launchPass(jsonOutput, stream, adapter, mobileTarget, tier, displayMode, target, state, remoteFlags);
|
|
677
786
|
}
|
|
678
|
-
function launchPass(json, stream, adapter, mobileTarget, tier, displayMode, target, state) {
|
|
787
|
+
function launchPass(json, stream, adapter, mobileTarget, tier, displayMode, target, state, remoteFlags = {}) {
|
|
679
788
|
if (stream.enabled) {
|
|
680
789
|
for (const mutation of state.mutations) stream.mutation(mutation);
|
|
681
790
|
for (const recovery of state.recovered) stream.recovery(recovery);
|
|
@@ -685,7 +794,8 @@ function launchPass(json, stream, adapter, mobileTarget, tier, displayMode, targ
|
|
|
685
794
|
tier,
|
|
686
795
|
recovered: state.recovered,
|
|
687
796
|
mutations: state.mutations,
|
|
688
|
-
metro: metroLaunchEvidence(adapter, target)
|
|
797
|
+
metro: metroLaunchEvidence(adapter, target),
|
|
798
|
+
remoteFeatureFlags: remoteFlags
|
|
689
799
|
});
|
|
690
800
|
} else if (json) {
|
|
691
801
|
console.log(
|
|
@@ -701,6 +811,7 @@ function launchPass(json, stream, adapter, mobileTarget, tier, displayMode, targ
|
|
|
701
811
|
recovered: state.recovered,
|
|
702
812
|
mutations: state.mutations,
|
|
703
813
|
metro: metroLaunchEvidence(adapter, target),
|
|
814
|
+
remoteFeatureFlags: remoteFlags,
|
|
704
815
|
exitCode: EXIT.ok
|
|
705
816
|
},
|
|
706
817
|
null,
|
|
@@ -4,9 +4,11 @@ import {
|
|
|
4
4
|
recordMobileSourceBaseline
|
|
5
5
|
} from "../../adapters/mobile/source-freshness.js";
|
|
6
6
|
import { ensureHarnessFresh } from "../../adapters/harness-freshness.js";
|
|
7
|
+
import { pinMobileFeatureFlags } from "../../adapters/mobile/feature-flags.js";
|
|
8
|
+
import { toRemoteFlagSpec } from "../../feature-flags.js";
|
|
7
9
|
import { clearMobileReleaseArtifactState } from "../../adapters/mobile/release-artifact-state.js";
|
|
8
10
|
import { EXIT } from "../shared.js";
|
|
9
|
-
async function launchMobile(target, mobileTarget, tier, json, restartApp = false, clearMetro = false) {
|
|
11
|
+
async function launchMobile(target, mobileTarget, tier, json, restartApp = false, clearMetro = false, remoteFlags) {
|
|
10
12
|
clearMobileReleaseArtifactState(target);
|
|
11
13
|
await ensureHarnessFresh(target, "mobile");
|
|
12
14
|
const platform = mobileTarget ?? "ios";
|
|
@@ -22,13 +24,35 @@ async function launchMobile(target, mobileTarget, tier, json, restartApp = false
|
|
|
22
24
|
clearMetro
|
|
23
25
|
});
|
|
24
26
|
if (result.status !== 0) return result;
|
|
27
|
+
const flags = remoteFlags ?? {};
|
|
28
|
+
const pin = await pinMobileFeatureFlags(target, flags);
|
|
29
|
+
let provenFlags = flags;
|
|
30
|
+
if (!pin.ok && !remoteFlags && pin.error) {
|
|
31
|
+
provenFlags = null;
|
|
32
|
+
if (!json) process.stderr.write(`WARN: remote feature-flag overrides were not cleared: ${pin.error}
|
|
33
|
+
`);
|
|
34
|
+
} else if (!pin.ok) {
|
|
35
|
+
const detail = pin.error ?? `did not read back: ${pin.mismatched.join(", ")} (got ${JSON.stringify(
|
|
36
|
+
Object.fromEntries(pin.mismatched.map((key) => [key, pin.effective[key] ?? pin.overrides[key]]))
|
|
37
|
+
)})`;
|
|
38
|
+
const message = `REMOTE_FLAG_NOT_APPLIED: remote feature flags ${toRemoteFlagSpec(flags) || "(none: clearing overrides)"} ${detail}
|
|
39
|
+
Next: confirm the app is running with the harness bridge, then re-run mm-harness launch`;
|
|
40
|
+
if (!json) process.stderr.write(`${message}
|
|
41
|
+
`);
|
|
42
|
+
return { status: EXIT.runtime, output: message };
|
|
43
|
+
} else if (!json) {
|
|
44
|
+
process.stderr.write(
|
|
45
|
+
pin.applied.length ? `Remote feature flags pinned: ${pin.applied.join(", ")}
|
|
46
|
+
` : "Remote feature flag overrides cleared.\n"
|
|
47
|
+
);
|
|
48
|
+
}
|
|
25
49
|
if (!recordMobileSourceBaseline(target, source.fingerprint)) {
|
|
26
50
|
const message = "Mobile source changed while the app was launching; the loaded-source baseline was not recorded.\n Next: re-run mm-harness launch.";
|
|
27
51
|
if (!json) process.stderr.write(`${message}
|
|
28
52
|
`);
|
|
29
53
|
return { status: EXIT.runtime, output: message };
|
|
30
54
|
}
|
|
31
|
-
return result;
|
|
55
|
+
return { ...result, remoteFeatureFlags: provenFlags };
|
|
32
56
|
}
|
|
33
57
|
export {
|
|
34
58
|
launchMobile
|
|
@@ -21,9 +21,16 @@ async function mobileDeviceLiveView(target, view) {
|
|
|
21
21
|
liveMap,
|
|
22
22
|
devicesWithLive,
|
|
23
23
|
additionalReachableDevices: reachableDevices,
|
|
24
|
-
androidPortHints: selectedAndroidPortHints(view.devices, liveMap)
|
|
24
|
+
androidPortHints: selectedAndroidPortHints(view.devices, liveMap),
|
|
25
|
+
featureFlagOverrides: slotFeatureFlagOverrides(liveMap)
|
|
25
26
|
};
|
|
26
27
|
}
|
|
28
|
+
function slotFeatureFlagOverrides(liveMap) {
|
|
29
|
+
for (const live of liveMap.values()) {
|
|
30
|
+
if (live.featureFlagOverrides) return live.featureFlagOverrides;
|
|
31
|
+
}
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
27
34
|
function scopedLiveMap(devices, allLiveMap) {
|
|
28
35
|
const liveMap = /* @__PURE__ */ new Map();
|
|
29
36
|
for (const device of devices) {
|
package/dist/commands/prepare.js
CHANGED
|
@@ -11,6 +11,7 @@ import { harnessExecutable, manifestPath, recipeRuntimeDir } from "../paths.js";
|
|
|
11
11
|
import { optionFlag, optionString, parseArgs, targetPath, usageError } from "./parse-args.js";
|
|
12
12
|
import { EXIT } from "./shared.js";
|
|
13
13
|
import { formatDuration } from "./status-watch.js";
|
|
14
|
+
import { FeatureFlagSpecError, parseRemoteFlagSpec } from "../feature-flags.js";
|
|
14
15
|
const USAGE = "mm-harness prepare [--target <checkout>] [--platform mobile|extension|core] [--mobile-platform ios|android] [--clear-metro] [--artifacts-dir <dir>] [--json]";
|
|
15
16
|
const REPORT_DIR = "prepare";
|
|
16
17
|
const SKIPPED_AFTER_FAILURE = "previous step failed";
|
|
@@ -318,6 +319,21 @@ async function handlePrepare(argv) {
|
|
|
318
319
|
throw usageError(`--clear-metro is mobile only; this checkout is ${platform}
|
|
319
320
|
Next: ${USAGE}`);
|
|
320
321
|
}
|
|
322
|
+
const remoteFlag = optionString(options, "remoteFlag");
|
|
323
|
+
if (remoteFlag !== void 0) {
|
|
324
|
+
if (platform === "core") {
|
|
325
|
+
throw usageError(`--remote-flag needs an app runtime; this checkout is core
|
|
326
|
+
Next: ${USAGE}`);
|
|
327
|
+
}
|
|
328
|
+
try {
|
|
329
|
+
parseRemoteFlagSpec(remoteFlag);
|
|
330
|
+
} catch (error) {
|
|
331
|
+
if (!(error instanceof FeatureFlagSpecError)) throw error;
|
|
332
|
+
throw usageError(`${error.message}
|
|
333
|
+
Next: ${error.userAction}`);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
const remoteFlagArgs = remoteFlag === void 0 ? [] : ["--remote-flag", remoteFlag];
|
|
321
337
|
let mobilePlatform = mobilePlatformOption(optionString(options, "mobilePlatform"));
|
|
322
338
|
const recordPath = path.join(artifactsDir, READINESS_RECORD);
|
|
323
339
|
const chain = stepChain(platform);
|
|
@@ -353,17 +369,18 @@ async function handlePrepare(argv) {
|
|
|
353
369
|
addStep(skippedStep("fixtures", `${executable} fixtures set`, reason));
|
|
354
370
|
} else if (platform === "mobile") {
|
|
355
371
|
const device = mobilePlatform ? [mobilePlatform] : [];
|
|
356
|
-
await run("launch", ["launch", ...device, ...clearMetro ? ["--clear-metro"] : [], "--verify", "--json", ...targetArgs]);
|
|
372
|
+
await run("launch", ["launch", ...device, ...clearMetro ? ["--clear-metro"] : [], ...remoteFlagArgs, "--verify", "--json", ...targetArgs]);
|
|
357
373
|
await run("fixtures", [
|
|
358
374
|
"fixtures",
|
|
359
375
|
"set",
|
|
360
376
|
...mobilePlatform ? ["--platform", mobilePlatform] : [],
|
|
377
|
+
...remoteFlagArgs,
|
|
361
378
|
"--json",
|
|
362
379
|
...targetArgs
|
|
363
380
|
]);
|
|
364
381
|
} else {
|
|
365
|
-
await run("launch", ["launch", "--verify", "--json", ...targetArgs]);
|
|
366
|
-
await run("fixtures", ["fixtures", "set", "--json", ...targetArgs]);
|
|
382
|
+
await run("launch", ["launch", ...remoteFlagArgs, "--verify", "--json", ...targetArgs]);
|
|
383
|
+
await run("fixtures", ["fixtures", "set", ...remoteFlagArgs, "--json", ...targetArgs]);
|
|
367
384
|
}
|
|
368
385
|
await run("verify", ["verify", "--json", "--adapter", platform, ...targetArgs]);
|
|
369
386
|
} finally {
|
|
@@ -1344,7 +1344,16 @@ async function ensureExtensionProofRuntime(target, heal, state, json, deps = {})
|
|
|
1344
1344
|
);
|
|
1345
1345
|
const launch = deps.launch ?? (async (requestedTier) => {
|
|
1346
1346
|
const { launchExtension } = await import("./launch/extension.js");
|
|
1347
|
-
|
|
1347
|
+
const { readExtensionPinnedFlags } = await import("../adapters/extension/feature-flags.js");
|
|
1348
|
+
const { fixtureFeatureFlags, remoteFlagRequestFor } = await import("../feature-flags.js");
|
|
1349
|
+
const { walletFixturePath } = await import("../paths.js");
|
|
1350
|
+
return launchExtension(
|
|
1351
|
+
target,
|
|
1352
|
+
requestedTier,
|
|
1353
|
+
false,
|
|
1354
|
+
"fullscreen",
|
|
1355
|
+
remoteFlagRequestFor(readExtensionPinnedFlags(target).pinned ?? fixtureFeatureFlags(walletFixturePath(target)))
|
|
1356
|
+
);
|
|
1348
1357
|
});
|
|
1349
1358
|
const result = await launch(tier);
|
|
1350
1359
|
const verified = result.status === 0 ? await decide() : initial;
|
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
targetPath,
|
|
21
21
|
usageError
|
|
22
22
|
} from "./parse-args.js";
|
|
23
|
+
import { FeatureFlagSpecError, parseRemoteFlagSpec } from "../feature-flags.js";
|
|
23
24
|
async function handleRuntimeLaunch({ options }) {
|
|
24
25
|
if (optionFlag(options, "help")) {
|
|
25
26
|
console.log(`mm-harness runtime-launch --adapter <extension|mobile> --target <checkout> [flags]
|
|
@@ -129,7 +130,15 @@ async function handleRuntimeLaunch({ options }) {
|
|
|
129
130
|
if (chromeUserDataDir) command.push("--chrome-user-data-dir", chromeUserDataDir);
|
|
130
131
|
command.push(optionFlag(options, "resetProfile") ? "--reset-profile" : "--preserve-profile");
|
|
131
132
|
const remoteFlag = optionString(options, "remoteFlag");
|
|
132
|
-
if (remoteFlag)
|
|
133
|
+
if (remoteFlag) {
|
|
134
|
+
try {
|
|
135
|
+
command.push("--remote-flags-json", JSON.stringify(parseRemoteFlagSpec(remoteFlag)));
|
|
136
|
+
} catch (error) {
|
|
137
|
+
if (!(error instanceof FeatureFlagSpecError)) throw error;
|
|
138
|
+
throw usageError(`${error.message}
|
|
139
|
+
Next: ${error.userAction}`);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
133
142
|
if (!fs.existsSync(liveScript)) {
|
|
134
143
|
const report2 = runtimeLaunchReport("fail", {
|
|
135
144
|
adapter,
|
|
@@ -2,6 +2,7 @@ import { spawn } from "node:child_process";
|
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { fixtureSummary } from "../doctor.js";
|
|
5
|
+
import { normalizeFeatureFlagRecord } from "../feature-flags.js";
|
|
5
6
|
import { runnerDir } from "../paths.js";
|
|
6
7
|
const PROBE_TIMEOUT_MS = 2e3;
|
|
7
8
|
function bridgeScriptPath() {
|
|
@@ -11,6 +12,15 @@ function truncateAddress(address) {
|
|
|
11
12
|
if (!address || address.length <= 10) return address;
|
|
12
13
|
return `${address.slice(0, 6)}\u2026${address.slice(-4)}`;
|
|
13
14
|
}
|
|
15
|
+
function entryFeatureFlagOverrides(entry) {
|
|
16
|
+
const flags = entry.featureFlags;
|
|
17
|
+
if (!flags || typeof flags !== "object") return void 0;
|
|
18
|
+
try {
|
|
19
|
+
return normalizeFeatureFlagRecord(flags.overrides ?? {}, "RemoteFeatureFlagController.localOverrides");
|
|
20
|
+
} catch {
|
|
21
|
+
return void 0;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
14
24
|
function routeToString(route) {
|
|
15
25
|
if (typeof route === "string") return route;
|
|
16
26
|
if (route && typeof route === "object" && "name" in route) {
|
|
@@ -38,7 +48,8 @@ function parseBridgeEntries(raw) {
|
|
|
38
48
|
platform: typeof e.platform === "string" ? e.platform : "",
|
|
39
49
|
// agenticPresent is only set when the bridge explicitly sends it (false = absent).
|
|
40
50
|
// An omitted value remains unknown.
|
|
41
|
-
...typeof e.agenticPresent === "boolean" ? { agenticPresent: e.agenticPresent } : {}
|
|
51
|
+
...typeof e.agenticPresent === "boolean" ? { agenticPresent: e.agenticPresent } : {},
|
|
52
|
+
...e.featureFlags !== void 0 ? { featureFlags: e.featureFlags } : {}
|
|
42
53
|
}));
|
|
43
54
|
}
|
|
44
55
|
function matchBridgeEntry(device, entries, devices) {
|
|
@@ -160,9 +171,11 @@ async function probeMobileLiveState(target, devices) {
|
|
|
160
171
|
const currentScreen = routeToString(entry.route);
|
|
161
172
|
const addrRaw = typeof account?.address === "string" ? account.address : void 0;
|
|
162
173
|
const labelRaw = typeof account?.name === "string" ? account.name : void 0;
|
|
174
|
+
const overrides = entryFeatureFlagOverrides(entry);
|
|
163
175
|
const liveEntry = {
|
|
164
176
|
fixtureStatus,
|
|
165
177
|
walletState,
|
|
178
|
+
...overrides !== void 0 ? { featureFlagOverrides: overrides } : {},
|
|
166
179
|
...currentScreen !== void 0 ? { currentScreen } : {},
|
|
167
180
|
...labelRaw !== void 0 && addrRaw !== void 0 ? { selectedAccount: { label: labelRaw, address: truncateAddress(addrRaw) } } : {}
|
|
168
181
|
};
|
package/dist/commands/status.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import { readExtensionPinnedFlags } from "../adapters/extension/feature-flags.js";
|
|
4
|
+
import { featureFlagReport } from "../feature-flags.js";
|
|
3
5
|
import { color } from "../cli-color.js";
|
|
4
6
|
import { collectCheckoutView, renderCheckoutView } from "../checkout-view.js";
|
|
5
7
|
import { detectAdapter } from "../harness.js";
|
|
@@ -49,6 +51,8 @@ async function handleStatus({ options }) {
|
|
|
49
51
|
}
|
|
50
52
|
}
|
|
51
53
|
const next = surface.hints.relaunch;
|
|
54
|
+
const extensionFlags = adapter === "extension" ? readExtensionPinnedFlags(target) : null;
|
|
55
|
+
const staticFeatureFlags = extensionFlags ? featureFlagReport(extensionFlags.overrides, extensionFlags) : featureFlagReport(null);
|
|
52
56
|
const deviceView = adapter === "mobile" ? mobileDeviceView(allDevices) : null;
|
|
53
57
|
const devices = deviceView?.devices ?? [];
|
|
54
58
|
const discovery = deviceView?.deviceDiscoveryErrors.length ? { deviceDiscoveryErrors: deviceView.deviceDiscoveryErrors } : {};
|
|
@@ -66,6 +70,7 @@ async function handleStatus({ options }) {
|
|
|
66
70
|
target,
|
|
67
71
|
view,
|
|
68
72
|
devices: liveView.devicesWithLive,
|
|
73
|
+
featureFlags: featureFlagReport(liveView.featureFlagOverrides),
|
|
69
74
|
...discovery,
|
|
70
75
|
...liveView.additionalReachableDevices.length > 0 ? { additionalReachableDevices: liveView.additionalReachableDevices } : {},
|
|
71
76
|
...liveView.androidPortHints.length > 0 ? { androidPortHints: liveView.androidPortHints } : {},
|
|
@@ -86,6 +91,7 @@ async function handleStatus({ options }) {
|
|
|
86
91
|
target,
|
|
87
92
|
view,
|
|
88
93
|
devices,
|
|
94
|
+
featureFlags: staticFeatureFlags,
|
|
89
95
|
...discovery,
|
|
90
96
|
...runtime ? { runtime } : {},
|
|
91
97
|
next: discoveryNext ?? (runtime ? nextForRuntime(surface.hints.launch, surface.hints.relaunch, runtime) : next)
|
|
@@ -100,10 +106,12 @@ async function handleStatus({ options }) {
|
|
|
100
106
|
const out = (style, text) => color(style, text, { stream: process.stdout });
|
|
101
107
|
console.log(`${out("label", "status")} ${out("bold", adapter)} ${out("dim", target)}`);
|
|
102
108
|
console.log(renderCheckoutView(view));
|
|
109
|
+
if (adapter !== "mobile") renderFeatureFlagLine(staticFeatureFlags, out);
|
|
103
110
|
if (deviceView) renderMobileDeviceList(deviceView, out);
|
|
104
111
|
if (!fast && adapter === "mobile" && deviceView !== null && deviceView.allConnectedDevices.length > 0) {
|
|
105
112
|
const liveView = await mobileDeviceLiveView(target, deviceView);
|
|
106
113
|
if (liveView.additionalReachableDevices.length > 0) renderAdditionalReachableDevices(liveView.additionalReachableDevices, out);
|
|
114
|
+
renderFeatureFlagLine(featureFlagReport(liveView.featureFlagOverrides), out);
|
|
107
115
|
renderMobileLiveBlock(devices, liveView.liveMap, out);
|
|
108
116
|
renderAndroidPortHints(liveView.androidPortHints, out);
|
|
109
117
|
console.log(`${out("label", "Next:")} ${out("cmd", discoveryNext ?? nextForLive(next, liveView.liveMap))}`);
|
|
@@ -112,10 +120,15 @@ async function handleStatus({ options }) {
|
|
|
112
120
|
renderRuntimeStatus(runtime, out);
|
|
113
121
|
console.log(`${out("label", "Next:")} ${out("cmd", nextForRuntime(surface.hints.launch, surface.hints.relaunch, runtime))}`);
|
|
114
122
|
} else {
|
|
123
|
+
if (adapter === "mobile") renderFeatureFlagLine(featureFlagReport(null), out);
|
|
115
124
|
console.log(`${out("label", "Next:")} ${out("cmd", discoveryNext ?? next)}`);
|
|
116
125
|
}
|
|
117
126
|
return EXIT.ok;
|
|
118
127
|
}
|
|
128
|
+
function renderFeatureFlagLine(report, out) {
|
|
129
|
+
const style = report.error ? "warn" : (report.overrideCount ?? 0) > 0 ? "warn" : "dim";
|
|
130
|
+
console.log(`${out("label", "feature flags:")} ${out(style, report.summary.replace(/^feature flags: /u, ""))}`);
|
|
131
|
+
}
|
|
119
132
|
function renderRuntimeStatus(runtime, out) {
|
|
120
133
|
const decisionStyle = runtime.decision === "ready" ? "ok" : runtime.decision === "blocked" ? "err" : "warn";
|
|
121
134
|
const deps = runtime.deps ? ` deps=${out(runtime.deps === "current" ? "ok" : "warn", runtime.deps)}` : "";
|
package/dist/doctor.js
CHANGED
|
@@ -2,6 +2,7 @@ import fs from "node:fs";
|
|
|
2
2
|
import { createRequire } from "node:module";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { color } from "./cli-color.js";
|
|
5
|
+
import { normalizeFeatureFlagRecord } from "./feature-flags.js";
|
|
5
6
|
import {
|
|
6
7
|
mobileToolRecovery,
|
|
7
8
|
resolveMobileToolPath
|
|
@@ -85,11 +86,18 @@ function fixtureFileSummary(file, adapter, reportedPath = file) {
|
|
|
85
86
|
const data = readJsonObject(file);
|
|
86
87
|
const hasAccounts = Array.isArray(data.accounts) && data.accounts.length > 0;
|
|
87
88
|
const hasPassword = typeof data.password === "string" && data.password.length > 0;
|
|
89
|
+
const remoteFeatureFlags = normalizeFeatureFlagRecord(
|
|
90
|
+
data["remoteFeatureFlags"],
|
|
91
|
+
String(reportedPath ?? file)
|
|
92
|
+
);
|
|
93
|
+
const flagKeys = Object.keys(remoteFeatureFlags);
|
|
88
94
|
return {
|
|
89
95
|
status: hasAccounts && (adapter === "core" || hasPassword) ? "ready" : "incomplete",
|
|
90
96
|
path: reportedPath,
|
|
91
97
|
accountCount: Array.isArray(data.accounts) ? data.accounts.length : 0,
|
|
92
|
-
hasPassword
|
|
98
|
+
hasPassword,
|
|
99
|
+
remoteFeatureFlagCount: flagKeys.length,
|
|
100
|
+
...flagKeys.length > 0 ? { remoteFeatureFlags } : {}
|
|
93
101
|
};
|
|
94
102
|
} catch (error) {
|
|
95
103
|
return { status: "invalid", path: reportedPath, error: error instanceof Error ? error.message : String(error) };
|