as-test 1.4.1 → 1.5.1
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 +64 -0
- package/assembly/src/expectation.ts +2 -2
- package/assembly/src/stringify.ts +29 -4
- package/bin/commands/build-core.js +62 -6
- package/bin/commands/fuzz-core.js +22 -1
- package/bin/commands/init-core.js +371 -36
- package/bin/commands/run-core.js +130 -21
- package/bin/commands/web-session.js +50 -3
- package/bin/index.js +6 -2
- package/bin/wipc.js +46 -7
- package/lib/build/index.js +45 -15
- package/lib/build/web-runner/worker.js +27 -0
- package/lib/src/index.ts +66 -15
- package/package.json +18 -17
- package/transform/lib/equals.js +3 -3
- package/transform/lib/index.js +10 -1
- package/transform/lib/mock.js +63 -20
|
@@ -3,9 +3,169 @@ import { spawnSync } from "child_process";
|
|
|
3
3
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
4
4
|
import * as path from "path";
|
|
5
5
|
import { createInterface } from "readline";
|
|
6
|
-
import { getCliVersion } from "../util.js";
|
|
6
|
+
import { getCliVersion, getExec } from "../util.js";
|
|
7
7
|
import { buildWebRunnerSource } from "./web-runner-source.js";
|
|
8
8
|
const TARGETS = ["wasi", "bindings", "web"];
|
|
9
|
+
// Popular runtimes offered by the interactive picker. Availability is probed
|
|
10
|
+
// against the machine/project (PATH for native binaries, node_modules for
|
|
11
|
+
// Playwright) so unavailable entries can be dimmed rather than hidden.
|
|
12
|
+
const RUNTIMES = [
|
|
13
|
+
{
|
|
14
|
+
value: "node:wasi",
|
|
15
|
+
label: "Node.js",
|
|
16
|
+
target: "wasi",
|
|
17
|
+
cmd: "node .as-test/runners/default.wasi.js",
|
|
18
|
+
browser: "",
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
value: "node:bindings",
|
|
22
|
+
label: "Node.js",
|
|
23
|
+
target: "bindings",
|
|
24
|
+
cmd: "node .as-test/runners/default.bindings.js",
|
|
25
|
+
browser: "",
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
value: "wasmtime",
|
|
29
|
+
label: "Wasmtime",
|
|
30
|
+
target: "wasi",
|
|
31
|
+
cmd: "wasmtime run <file>",
|
|
32
|
+
browser: "",
|
|
33
|
+
bin: "wasmtime",
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
value: "wasmer",
|
|
37
|
+
label: "Wasmer",
|
|
38
|
+
target: "wasi",
|
|
39
|
+
cmd: "wasmer run <file>",
|
|
40
|
+
browser: "",
|
|
41
|
+
bin: "wasmer",
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
value: "wazero",
|
|
45
|
+
label: "wazero",
|
|
46
|
+
target: "wasi",
|
|
47
|
+
cmd: "wazero run <file>",
|
|
48
|
+
browser: "",
|
|
49
|
+
bin: "wazero",
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
value: "chromium",
|
|
53
|
+
label: "Chromium",
|
|
54
|
+
target: "web",
|
|
55
|
+
cmd: "node .as-test/runners/default.web.js",
|
|
56
|
+
browser: "chromium",
|
|
57
|
+
needsPlaywright: true,
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
value: "firefox",
|
|
61
|
+
label: "Firefox",
|
|
62
|
+
target: "web",
|
|
63
|
+
cmd: "node .as-test/runners/default.web.js",
|
|
64
|
+
browser: "firefox",
|
|
65
|
+
needsPlaywright: true,
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
value: "webkit",
|
|
69
|
+
label: "WebKit",
|
|
70
|
+
target: "web",
|
|
71
|
+
cmd: "node .as-test/runners/default.web.js",
|
|
72
|
+
browser: "webkit",
|
|
73
|
+
needsPlaywright: true,
|
|
74
|
+
},
|
|
75
|
+
];
|
|
76
|
+
// Probe whether a runtime is usable on this machine/project. Node-based
|
|
77
|
+
// runtimes are always available (we are running under Node); native binaries
|
|
78
|
+
// must resolve on PATH; browser runtimes require Playwright to be installed.
|
|
79
|
+
function probeRuntime(runtime, root) {
|
|
80
|
+
if (runtime.bin) {
|
|
81
|
+
if (getExec(runtime.bin)) return { available: true };
|
|
82
|
+
return { available: false, hint: `${runtime.bin} not on PATH` };
|
|
83
|
+
}
|
|
84
|
+
if (runtime.needsPlaywright) {
|
|
85
|
+
const pkg = path.join(root, "node_modules", "playwright", "package.json");
|
|
86
|
+
if (existsSync(pkg)) return { available: true };
|
|
87
|
+
return { available: false, hint: "playwright not installed" };
|
|
88
|
+
}
|
|
89
|
+
return { available: true };
|
|
90
|
+
}
|
|
91
|
+
// The default runtime command for a build target, matching the long-standing
|
|
92
|
+
// `--target`/`--yes` behaviour (plain Node runner, no browser).
|
|
93
|
+
function runtimeForTarget(target) {
|
|
94
|
+
const cmd =
|
|
95
|
+
target == "wasi"
|
|
96
|
+
? "node .as-test/runners/default.wasi.js"
|
|
97
|
+
: target == "bindings"
|
|
98
|
+
? "node .as-test/runners/default.bindings.js"
|
|
99
|
+
: "node .as-test/runners/default.web.js";
|
|
100
|
+
return { cmd, browser: "" };
|
|
101
|
+
}
|
|
102
|
+
function defaultRuntimeLabel(target) {
|
|
103
|
+
if (target == "wasi") return "node:wasi";
|
|
104
|
+
if (target == "bindings") return "node:bindings";
|
|
105
|
+
return "web";
|
|
106
|
+
}
|
|
107
|
+
// Ensure a mode key is unique against names already in use, suffixing -2, -3…
|
|
108
|
+
function uniqueModeName(name, taken) {
|
|
109
|
+
if (!taken.includes(name)) return name;
|
|
110
|
+
for (let i = 2; ; i++) {
|
|
111
|
+
const candidate = `${name}-${i}`;
|
|
112
|
+
if (!taken.includes(candidate)) return candidate;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
// Prompt for a user-defined runtime: a command (with optional <file>
|
|
116
|
+
// placeholder), a mode name, and — for the web target — an optional browser.
|
|
117
|
+
// Returns null if no command is entered or interactive input is unavailable.
|
|
118
|
+
async function askCustomRuntime(target, face, taken) {
|
|
119
|
+
if (!face) return null;
|
|
120
|
+
const cmd = (
|
|
121
|
+
await ask(
|
|
122
|
+
`${chalk.bold.blue("◇ Custom runtime command")}\n` +
|
|
123
|
+
`${chalk.dim("│ <file> is replaced with the generated .wasm — e.g. wasmtime <file>")}\n│ `,
|
|
124
|
+
face,
|
|
125
|
+
"",
|
|
126
|
+
)
|
|
127
|
+
).trim();
|
|
128
|
+
if (!cmd.length) return null;
|
|
129
|
+
const rawName = (
|
|
130
|
+
await ask(
|
|
131
|
+
`${chalk.bold.blue("◇ Name for this runtime (used as the mode key, default: custom)")}\n│ `,
|
|
132
|
+
face,
|
|
133
|
+
"",
|
|
134
|
+
)
|
|
135
|
+
).trim();
|
|
136
|
+
const name = uniqueModeName(rawName.length ? rawName : "custom", taken);
|
|
137
|
+
let browser = "";
|
|
138
|
+
if (target == "web") {
|
|
139
|
+
browser = (
|
|
140
|
+
await ask(
|
|
141
|
+
`${chalk.bold.blue("◇ Browser (optional: chromium / firefox / webkit)")}\n│ `,
|
|
142
|
+
face,
|
|
143
|
+
"",
|
|
144
|
+
)
|
|
145
|
+
).trim();
|
|
146
|
+
}
|
|
147
|
+
printSelectionLine(`${name}: ${cmd}${browser ? ` (${browser})` : ""}`);
|
|
148
|
+
return { value: name, target, cmd, browser };
|
|
149
|
+
}
|
|
150
|
+
// Turn the selected runtimes into config `modes` — one named mode per runtime,
|
|
151
|
+
// each carrying its build target and runtime command. Every mode runs by
|
|
152
|
+
// default (the absent `default` flag defaults to true), so `ast test` executes
|
|
153
|
+
// them all.
|
|
154
|
+
function buildRuntimeModes(selected) {
|
|
155
|
+
const modes = {};
|
|
156
|
+
for (const rt of selected) {
|
|
157
|
+
modes[rt.value] = {
|
|
158
|
+
buildOptions: { target: rt.target },
|
|
159
|
+
runOptions: {
|
|
160
|
+
runtime: {
|
|
161
|
+
cmd: rt.cmd,
|
|
162
|
+
...(rt.browser ? { browser: rt.browser } : {}),
|
|
163
|
+
},
|
|
164
|
+
},
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
return modes;
|
|
168
|
+
}
|
|
9
169
|
const EXAMPLE_MODES = ["minimal", "full", "none"];
|
|
10
170
|
const FEATURE_KEYS = ["coverage", "tryAs"];
|
|
11
171
|
const FEATURE_LABELS = {
|
|
@@ -27,6 +187,9 @@ export async function init(rawArgs) {
|
|
|
27
187
|
? {
|
|
28
188
|
root: path.resolve(process.cwd(), options.dir),
|
|
29
189
|
target: options.target ?? "wasi",
|
|
190
|
+
runtime: runtimeForTarget(options.target ?? "wasi"),
|
|
191
|
+
modes: {},
|
|
192
|
+
runtimeLabel: defaultRuntimeLabel(options.target ?? "wasi"),
|
|
30
193
|
example: options.example ?? "minimal",
|
|
31
194
|
fuzzExample: options.fuzzExample ?? false,
|
|
32
195
|
features: resolveFeatures(options.features, {
|
|
@@ -43,6 +206,8 @@ export async function init(rawArgs) {
|
|
|
43
206
|
printPlan(
|
|
44
207
|
answers.root,
|
|
45
208
|
answers.target,
|
|
209
|
+
answers.runtimeLabel,
|
|
210
|
+
Object.keys(answers.modes),
|
|
46
211
|
answers.example,
|
|
47
212
|
answers.fuzzExample,
|
|
48
213
|
answers.features,
|
|
@@ -58,6 +223,8 @@ export async function init(rawArgs) {
|
|
|
58
223
|
const summary = applyInit(
|
|
59
224
|
answers.root,
|
|
60
225
|
answers.target,
|
|
226
|
+
answers.runtime,
|
|
227
|
+
answers.modes,
|
|
61
228
|
answers.example,
|
|
62
229
|
answers.fuzzExample,
|
|
63
230
|
answers.features,
|
|
@@ -265,6 +432,8 @@ async function runInteractiveOnboarding(options, face) {
|
|
|
265
432
|
} else {
|
|
266
433
|
printSelectionLine(resolvedRoot);
|
|
267
434
|
}
|
|
435
|
+
// Step 1: pick the build target (mode). The runtime list is then filtered to
|
|
436
|
+
// the runtimes that support this target.
|
|
268
437
|
const target =
|
|
269
438
|
options.target ??
|
|
270
439
|
(onboardingMode == "quick"
|
|
@@ -272,21 +441,9 @@ async function runInteractiveOnboarding(options, face) {
|
|
|
272
441
|
: await askMenuChoice(
|
|
273
442
|
"Build target",
|
|
274
443
|
[
|
|
275
|
-
{
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
"wasi (default runner: node .as-test/runners/default.wasi.js)",
|
|
279
|
-
},
|
|
280
|
-
{
|
|
281
|
-
value: "bindings",
|
|
282
|
-
label:
|
|
283
|
-
"bindings (default runner: node .as-test/runners/default.bindings.js)",
|
|
284
|
-
},
|
|
285
|
-
{
|
|
286
|
-
value: "web",
|
|
287
|
-
label:
|
|
288
|
-
"web (default runner: node .as-test/runners/default.web.js)",
|
|
289
|
-
},
|
|
444
|
+
{ value: "wasi", label: "wasi (WebAssembly System Interface)" },
|
|
445
|
+
{ value: "bindings", label: "bindings (Node.js host bindings)" },
|
|
446
|
+
{ value: "web", label: "web (browser via Playwright)" },
|
|
290
447
|
],
|
|
291
448
|
face,
|
|
292
449
|
"wasi",
|
|
@@ -294,6 +451,72 @@ async function runInteractiveOnboarding(options, face) {
|
|
|
294
451
|
if (options.target || onboardingMode == "quick") {
|
|
295
452
|
printPromptAndSelectionLine("Build target", target);
|
|
296
453
|
}
|
|
454
|
+
// Step 2: pick one or more runtimes, scoped to the chosen target. Unavailable
|
|
455
|
+
// runtimes (native binary missing from PATH, or Playwright not installed) are
|
|
456
|
+
// dimmed; "Custom…" lets the user define their own. Each chosen runtime
|
|
457
|
+
// becomes a config mode so `ast test` runs the whole matrix.
|
|
458
|
+
let runtime;
|
|
459
|
+
let runtimeLabel;
|
|
460
|
+
let modes = {};
|
|
461
|
+
const targetRuntimes = RUNTIMES.filter((rt) => rt.target == target);
|
|
462
|
+
if (options.target || onboardingMode == "quick") {
|
|
463
|
+
// Flag/quick runs are non-interactive: keep the historical Node default.
|
|
464
|
+
runtime = runtimeForTarget(target);
|
|
465
|
+
runtimeLabel = defaultRuntimeLabel(target);
|
|
466
|
+
printPromptAndSelectionLine("Runtime", runtimeLabel);
|
|
467
|
+
} else {
|
|
468
|
+
const toggleChoices = targetRuntimes.map((rt) => {
|
|
469
|
+
const status = probeRuntime(rt, resolvedRoot);
|
|
470
|
+
return {
|
|
471
|
+
value: rt.value,
|
|
472
|
+
label: rt.label,
|
|
473
|
+
disabled: !status.available,
|
|
474
|
+
hint: status.hint,
|
|
475
|
+
};
|
|
476
|
+
});
|
|
477
|
+
toggleChoices.push({
|
|
478
|
+
value: "custom",
|
|
479
|
+
label: "Custom…",
|
|
480
|
+
alwaysSelectable: true,
|
|
481
|
+
});
|
|
482
|
+
// Pre-select the first available built-in runtime so confirming immediately
|
|
483
|
+
// yields a sensible single choice.
|
|
484
|
+
const firstAvailable =
|
|
485
|
+
targetRuntimes.find((rt) => probeRuntime(rt, resolvedRoot).available)
|
|
486
|
+
?.value ?? targetRuntimes[0].value;
|
|
487
|
+
const initial = {};
|
|
488
|
+
for (const choice of toggleChoices) {
|
|
489
|
+
initial[choice.value] = choice.value == firstAvailable;
|
|
490
|
+
}
|
|
491
|
+
const result = await askMultiToggle(
|
|
492
|
+
"Runtimes (↑/↓ move, space toggle, enter confirm — dimmed = not detected)",
|
|
493
|
+
toggleChoices,
|
|
494
|
+
face,
|
|
495
|
+
initial,
|
|
496
|
+
);
|
|
497
|
+
const selected = targetRuntimes.filter((rt) => result[rt.value]);
|
|
498
|
+
if (result["custom"]) {
|
|
499
|
+
const custom = await askCustomRuntime(
|
|
500
|
+
target,
|
|
501
|
+
face,
|
|
502
|
+
selected.map((rt) => rt.value),
|
|
503
|
+
);
|
|
504
|
+
if (custom) selected.push(custom);
|
|
505
|
+
}
|
|
506
|
+
if (!selected.length) {
|
|
507
|
+
// Confirming with nothing selected falls back to the default runtime.
|
|
508
|
+
const fallback =
|
|
509
|
+
targetRuntimes.find((rt) => rt.value == firstAvailable) ??
|
|
510
|
+
targetRuntimes[0];
|
|
511
|
+
selected.push(fallback);
|
|
512
|
+
}
|
|
513
|
+
const primary = selected[0];
|
|
514
|
+
runtime = { cmd: primary.cmd, browser: primary.browser };
|
|
515
|
+
runtimeLabel = selected.map((rt) => rt.value).join(", ");
|
|
516
|
+
// The picker is authoritative: write a mode per chosen runtime so the
|
|
517
|
+
// exact selection (single, matrix, or custom) is what `ast test` runs.
|
|
518
|
+
modes = buildRuntimeModes(selected);
|
|
519
|
+
}
|
|
297
520
|
const featureDefaults = { coverage: false, tryAs: false };
|
|
298
521
|
const explicitFeatures =
|
|
299
522
|
options.features.coverage !== undefined ||
|
|
@@ -355,6 +578,9 @@ async function runInteractiveOnboarding(options, face) {
|
|
|
355
578
|
return {
|
|
356
579
|
root: resolvedRoot,
|
|
357
580
|
target,
|
|
581
|
+
runtime,
|
|
582
|
+
modes,
|
|
583
|
+
runtimeLabel,
|
|
358
584
|
example,
|
|
359
585
|
fuzzExample,
|
|
360
586
|
features,
|
|
@@ -464,7 +690,16 @@ function isTarget(value) {
|
|
|
464
690
|
function isExampleMode(value) {
|
|
465
691
|
return EXAMPLE_MODES.includes(value);
|
|
466
692
|
}
|
|
467
|
-
function printPlan(
|
|
693
|
+
function printPlan(
|
|
694
|
+
root,
|
|
695
|
+
target,
|
|
696
|
+
runtimeLabel,
|
|
697
|
+
modeNames,
|
|
698
|
+
example,
|
|
699
|
+
fuzzExample,
|
|
700
|
+
features,
|
|
701
|
+
install,
|
|
702
|
+
) {
|
|
468
703
|
const displayRoot = () => {
|
|
469
704
|
const rel = path.relative(process.cwd(), root).split(path.sep).join("/");
|
|
470
705
|
if (!rel || rel == ".") return "./";
|
|
@@ -568,6 +803,15 @@ function printPlan(root, target, example, fuzzExample, features, install) {
|
|
|
568
803
|
const treeRoot = buildTree(fileEntries);
|
|
569
804
|
console.log(chalk.bold.blue("◇ Planned Changes"));
|
|
570
805
|
console.log("│" + chalk.dim(` - Target: ${target}`));
|
|
806
|
+
console.log(
|
|
807
|
+
"│" +
|
|
808
|
+
chalk.dim(
|
|
809
|
+
` - Runtime${runtimeLabel.includes(",") ? "s" : ""}: ${runtimeLabel}`,
|
|
810
|
+
),
|
|
811
|
+
);
|
|
812
|
+
if (modeNames.length) {
|
|
813
|
+
console.log("│" + chalk.dim(` - Modes: ${modeNames.join(", ")}`));
|
|
814
|
+
}
|
|
571
815
|
console.log("│" + chalk.dim(` - Example: ${example}`));
|
|
572
816
|
console.log(
|
|
573
817
|
"│" + chalk.dim(` - Fuzzer example: ${fuzzExample ? "yes" : "no"}`),
|
|
@@ -586,7 +830,16 @@ function printPlan(root, target, example, fuzzExample, features, install) {
|
|
|
586
830
|
}
|
|
587
831
|
console.log("│");
|
|
588
832
|
}
|
|
589
|
-
function applyInit(
|
|
833
|
+
function applyInit(
|
|
834
|
+
root,
|
|
835
|
+
target,
|
|
836
|
+
runtime,
|
|
837
|
+
modes,
|
|
838
|
+
example,
|
|
839
|
+
fuzzExample,
|
|
840
|
+
features,
|
|
841
|
+
force,
|
|
842
|
+
) {
|
|
590
843
|
const summary = {
|
|
591
844
|
created: [],
|
|
592
845
|
updated: [],
|
|
@@ -637,17 +890,17 @@ function applyInit(root, target, example, fuzzExample, features, force) {
|
|
|
637
890
|
},
|
|
638
891
|
runOptions: {
|
|
639
892
|
runtime: {
|
|
640
|
-
cmd:
|
|
641
|
-
|
|
642
|
-
? "node .as-test/runners/default.wasi.js"
|
|
643
|
-
: target == "bindings"
|
|
644
|
-
? "node .as-test/runners/default.bindings.js"
|
|
645
|
-
: "node .as-test/runners/default.web.js",
|
|
893
|
+
cmd: runtime.cmd,
|
|
894
|
+
...(runtime.browser ? { browser: runtime.browser } : {}),
|
|
646
895
|
},
|
|
647
896
|
reporter: "default",
|
|
648
897
|
},
|
|
649
|
-
|
|
650
|
-
|
|
898
|
+
// The interactive picker supplies one mode per selected runtime. The
|
|
899
|
+
// non-interactive paths (--yes/--target/quick) leave `modes` empty, so the
|
|
900
|
+
// historical web convenience modes are scaffolded for the web target.
|
|
901
|
+
modes: Object.keys(modes).length
|
|
902
|
+
? modes
|
|
903
|
+
: target == "web"
|
|
651
904
|
? {
|
|
652
905
|
web: {
|
|
653
906
|
default: false,
|
|
@@ -899,12 +1152,14 @@ async function askChoice(label, choices, face, fallback) {
|
|
|
899
1152
|
throw new Error(`Invalid choice "${answer}" for ${label}`);
|
|
900
1153
|
}
|
|
901
1154
|
async function askMenuChoice(label, choices, face, fallback) {
|
|
902
|
-
const
|
|
1155
|
+
const enabled = choices.filter((choice) => !choice.disabled);
|
|
1156
|
+
const pool = enabled.length ? enabled : choices;
|
|
1157
|
+
const fallbackValue = pool.some((choice) => choice.value == fallback)
|
|
903
1158
|
? fallback
|
|
904
|
-
:
|
|
1159
|
+
: pool[0].value;
|
|
905
1160
|
if (!face) return fallbackValue;
|
|
906
1161
|
if (!canUseArrowMenu(face)) {
|
|
907
|
-
const values =
|
|
1162
|
+
const values = pool.map((choice) => choice.value);
|
|
908
1163
|
return askChoice(label, values, face, fallbackValue);
|
|
909
1164
|
}
|
|
910
1165
|
return askMenuChoiceWithArrows(label, choices, face, fallbackValue);
|
|
@@ -915,7 +1170,16 @@ async function askMultiToggle(label, choices, face, initial) {
|
|
|
915
1170
|
return askMultiToggleWithArrows(label, choices, face, initial);
|
|
916
1171
|
}
|
|
917
1172
|
const result = { ...initial };
|
|
1173
|
+
const anySelectable = choices.some(
|
|
1174
|
+
(choice) => !choice.disabled && !choice.alwaysSelectable,
|
|
1175
|
+
);
|
|
918
1176
|
for (const choice of choices) {
|
|
1177
|
+
const selectable =
|
|
1178
|
+
Boolean(choice.alwaysSelectable) || !anySelectable || !choice.disabled;
|
|
1179
|
+
if (!selectable) {
|
|
1180
|
+
result[choice.value] = false;
|
|
1181
|
+
continue;
|
|
1182
|
+
}
|
|
919
1183
|
result[choice.value] = await askYesNo(
|
|
920
1184
|
`${label} — enable ${choice.label}?`,
|
|
921
1185
|
face,
|
|
@@ -961,8 +1225,32 @@ function canUseArrowMenu(face) {
|
|
|
961
1225
|
async function askMenuChoiceWithArrows(label, choices, face, fallback) {
|
|
962
1226
|
const stdin = process.stdin;
|
|
963
1227
|
const stdout = process.stdout;
|
|
964
|
-
|
|
965
|
-
|
|
1228
|
+
// When nothing is selectable (e.g. every runtime for a target is dimmed
|
|
1229
|
+
// because its tooling isn't installed yet), fall back to letting the user
|
|
1230
|
+
// pick anyway — the dimming stays as an informational warning.
|
|
1231
|
+
const anySelectable = choices.some((choice) => !choice.disabled);
|
|
1232
|
+
const isSelectable = (index) => !anySelectable || !choices[index].disabled;
|
|
1233
|
+
const firstSelectable = choices.findIndex((_, i) => isSelectable(i));
|
|
1234
|
+
const fallbackIndex = choices.findIndex(
|
|
1235
|
+
(choice) => choice.value == fallback && !choice.disabled,
|
|
1236
|
+
);
|
|
1237
|
+
let selectedIndex =
|
|
1238
|
+
fallbackIndex != -1
|
|
1239
|
+
? fallbackIndex
|
|
1240
|
+
: firstSelectable != -1
|
|
1241
|
+
? firstSelectable
|
|
1242
|
+
: 0;
|
|
1243
|
+
// Step from `selectedIndex` in `step` direction (wrapping) to the next
|
|
1244
|
+
// selectable option, ignoring disabled entries. Returns the current index
|
|
1245
|
+
// unchanged if nothing else is selectable.
|
|
1246
|
+
const stepSelection = (step) => {
|
|
1247
|
+
for (let i = 1; i <= choices.length; i++) {
|
|
1248
|
+
const candidate =
|
|
1249
|
+
(selectedIndex + step * i + choices.length * i) % choices.length;
|
|
1250
|
+
if (isSelectable(candidate)) return candidate;
|
|
1251
|
+
}
|
|
1252
|
+
return selectedIndex;
|
|
1253
|
+
};
|
|
966
1254
|
let renderedLineCount = 0;
|
|
967
1255
|
const previousRawMode = Boolean(stdin.isRaw);
|
|
968
1256
|
const lineWidth = Math.max(20, (stdout.columns ?? 80) - 2);
|
|
@@ -977,6 +1265,15 @@ async function askMenuChoiceWithArrows(label, choices, face, fallback) {
|
|
|
977
1265
|
const lines = [titleLine()];
|
|
978
1266
|
for (let i = 0; i < choices.length; i++) {
|
|
979
1267
|
const choice = choices[i];
|
|
1268
|
+
if (choice.disabled) {
|
|
1269
|
+
const text = choice.hint
|
|
1270
|
+
? `${choice.label} (${choice.hint})`
|
|
1271
|
+
: choice.label;
|
|
1272
|
+
lines.push(
|
|
1273
|
+
`│ ${chalk.dim("✕")} ${chalk.dim(clamp(text, Math.max(8, lineWidth - 6)))}`,
|
|
1274
|
+
);
|
|
1275
|
+
continue;
|
|
1276
|
+
}
|
|
980
1277
|
const marker = i == selectedIndex ? chalk.blue("●") : chalk.dim("○");
|
|
981
1278
|
lines.push(
|
|
982
1279
|
`│ ${marker} ${clamp(choice.label, Math.max(8, lineWidth - 6))}`,
|
|
@@ -1069,7 +1366,7 @@ async function askMenuChoiceWithArrows(label, choices, face, fallback) {
|
|
|
1069
1366
|
input == "\x1b[D" ||
|
|
1070
1367
|
input == "\x1bOD"
|
|
1071
1368
|
) {
|
|
1072
|
-
selectedIndex = (
|
|
1369
|
+
selectedIndex = stepSelection(-1);
|
|
1073
1370
|
writeLines(menuLines());
|
|
1074
1371
|
return;
|
|
1075
1372
|
}
|
|
@@ -1079,11 +1376,12 @@ async function askMenuChoiceWithArrows(label, choices, face, fallback) {
|
|
|
1079
1376
|
input == "\x1b[C" ||
|
|
1080
1377
|
input == "\x1bOC"
|
|
1081
1378
|
) {
|
|
1082
|
-
selectedIndex = (
|
|
1379
|
+
selectedIndex = stepSelection(1);
|
|
1083
1380
|
writeLines(menuLines());
|
|
1084
1381
|
return;
|
|
1085
1382
|
}
|
|
1086
1383
|
if (input == "\r" || input == "\n") {
|
|
1384
|
+
if (!isSelectable(selectedIndex)) return;
|
|
1087
1385
|
finish(choices[selectedIndex].value);
|
|
1088
1386
|
return;
|
|
1089
1387
|
}
|
|
@@ -1101,7 +1399,29 @@ async function askMultiToggleWithArrows(label, choices, face, initial) {
|
|
|
1101
1399
|
const stdin = process.stdin;
|
|
1102
1400
|
const stdout = process.stdout;
|
|
1103
1401
|
const selected = { ...initial };
|
|
1104
|
-
|
|
1402
|
+
// When no "real" option is selectable (e.g. every browser is dimmed because
|
|
1403
|
+
// Playwright isn't installed yet — which init can fix), allow toggling the
|
|
1404
|
+
// dimmed entries anyway; the dimming stays as an informational warning. The
|
|
1405
|
+
// always-selectable Custom… entry is excluded from this decision.
|
|
1406
|
+
const anySelectable = choices.some(
|
|
1407
|
+
(choice) => !choice.disabled && !choice.alwaysSelectable,
|
|
1408
|
+
);
|
|
1409
|
+
const isSelectable = (index) => {
|
|
1410
|
+
const choice = choices[index];
|
|
1411
|
+
return (
|
|
1412
|
+
Boolean(choice.alwaysSelectable) || !anySelectable || !choice.disabled
|
|
1413
|
+
);
|
|
1414
|
+
};
|
|
1415
|
+
const stepCursor = (step) => {
|
|
1416
|
+
for (let i = 1; i <= choices.length; i++) {
|
|
1417
|
+
const candidate =
|
|
1418
|
+
(cursorIndex + step * i + choices.length * i) % choices.length;
|
|
1419
|
+
if (isSelectable(candidate)) return candidate;
|
|
1420
|
+
}
|
|
1421
|
+
return cursorIndex;
|
|
1422
|
+
};
|
|
1423
|
+
let cursorIndex = choices.findIndex((_, i) => isSelectable(i));
|
|
1424
|
+
if (cursorIndex == -1) cursorIndex = 0;
|
|
1105
1425
|
let renderedLineCount = 0;
|
|
1106
1426
|
const previousRawMode = Boolean(stdin.isRaw);
|
|
1107
1427
|
const lineWidth = Math.max(20, (stdout.columns ?? 80) - 2);
|
|
@@ -1118,6 +1438,20 @@ async function askMultiToggleWithArrows(label, choices, face, initial) {
|
|
|
1118
1438
|
const choice = choices[i];
|
|
1119
1439
|
const isOn = Boolean(selected[choice.value]);
|
|
1120
1440
|
const cursor = i == cursorIndex ? chalk.blue("›") : " ";
|
|
1441
|
+
if (choice.disabled) {
|
|
1442
|
+
const text = choice.hint
|
|
1443
|
+
? `${choice.label} (${choice.hint})`
|
|
1444
|
+
: choice.label;
|
|
1445
|
+
const dimmed = chalk.dim(clamp(text, Math.max(8, lineWidth - 8)));
|
|
1446
|
+
// Selectable-but-dimmed (installable) keeps its ●/○; hard-blocked uses ✕.
|
|
1447
|
+
const marker = isSelectable(i)
|
|
1448
|
+
? isOn
|
|
1449
|
+
? chalk.blue("●")
|
|
1450
|
+
: chalk.dim("○")
|
|
1451
|
+
: chalk.dim("✕");
|
|
1452
|
+
lines.push(`│ ${cursor} ${marker} ${dimmed}`);
|
|
1453
|
+
continue;
|
|
1454
|
+
}
|
|
1121
1455
|
const marker = isOn ? chalk.blue("●") : chalk.dim("○");
|
|
1122
1456
|
const text = clamp(choice.label, Math.max(8, lineWidth - 6));
|
|
1123
1457
|
const painted = i == cursorIndex ? chalk.bold(text) : text;
|
|
@@ -1203,16 +1537,17 @@ async function askMultiToggleWithArrows(label, choices, face, initial) {
|
|
|
1203
1537
|
return;
|
|
1204
1538
|
}
|
|
1205
1539
|
if (input == "\x1b[A" || input == "\x1bOA") {
|
|
1206
|
-
cursorIndex = (
|
|
1540
|
+
cursorIndex = stepCursor(-1);
|
|
1207
1541
|
writeLines(menuLines());
|
|
1208
1542
|
return;
|
|
1209
1543
|
}
|
|
1210
1544
|
if (input == "\x1b[B" || input == "\x1bOB") {
|
|
1211
|
-
cursorIndex = (
|
|
1545
|
+
cursorIndex = stepCursor(1);
|
|
1212
1546
|
writeLines(menuLines());
|
|
1213
1547
|
return;
|
|
1214
1548
|
}
|
|
1215
1549
|
if (input == " ") {
|
|
1550
|
+
if (!isSelectable(cursorIndex)) return;
|
|
1216
1551
|
const key = choices[cursorIndex].value;
|
|
1217
1552
|
selected[key] = !selected[key];
|
|
1218
1553
|
writeLines(menuLines());
|