windows-development-sandbox 1.23.2 → 1.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/config.example.json +0 -1
  2. package/dist/bin/agent-sandbox.js +2 -2
  3. package/dist/bin/{chunk-USTRSHIE.js → chunk-IZRGTSQ5.js} +158 -131
  4. package/dist/bin/{chunk-QFSJX2KW.js → chunk-KH4MI2U5.js} +1 -1
  5. package/dist/bin/{chunk-7YPEDAJD.js → chunk-SUYYVIDW.js} +1 -1
  6. package/dist/bin/claude-host.js +2 -2
  7. package/dist/bin/claude-sbx.js +2 -2
  8. package/dist/bin/codex-sbx.js +2 -2
  9. package/dist/bin/gemini-sbx.js +2 -2
  10. package/dist/bin/sandbox.js +2 -2
  11. package/dist/bin/sbx-hide.js +2 -2
  12. package/dist/bin/sbx-show.js +2 -2
  13. package/dist/core/config/defaults.js +0 -1
  14. package/dist/core/detect/toolchains.js +0 -6
  15. package/dist/core/schema/config.d.ts +1 -2
  16. package/dist/core/schema/toolchains.d.ts +1 -2
  17. package/dist/core/schema/toolchains.js +3 -6
  18. package/dist/guest/agents/agent-files.d.ts +11 -7
  19. package/dist/guest/agents/agent-files.js +37 -11
  20. package/dist/guest/agents/local-state.d.ts +2 -1
  21. package/dist/guest/agents/local-state.js +2 -1
  22. package/dist/guest/boot/boot-console.d.ts +28 -0
  23. package/dist/guest/boot/boot-console.js +98 -0
  24. package/dist/guest/boot/boot.d.ts +8 -1
  25. package/dist/guest/boot/boot.js +66 -42
  26. package/dist/guest/boot/graph.d.ts +9 -5
  27. package/dist/guest/boot/graph.js +8 -2
  28. package/dist/guest/boot/progress-view.d.ts +116 -0
  29. package/dist/guest/boot/progress-view.js +456 -0
  30. package/dist/guest/boot/runner.d.ts +20 -10
  31. package/dist/guest/boot/runner.js +39 -47
  32. package/dist/guest/runtime/log-line.d.ts +7 -0
  33. package/dist/guest/runtime/log-line.js +18 -0
  34. package/dist/guest/runtime/log.d.ts +17 -4
  35. package/dist/guest/runtime/log.js +40 -17
  36. package/dist/guest/runtime/process.d.ts +9 -0
  37. package/dist/guest/runtime/process.js +69 -8
  38. package/dist/guest/setup/environment.js +9 -4
  39. package/dist/guest/setup/hooks.d.ts +2 -0
  40. package/dist/guest/setup/hooks.js +4 -0
  41. package/dist/guest/setup/path-plan.d.ts +12 -0
  42. package/dist/guest/setup/path-plan.js +18 -0
  43. package/dist/guest/toolchains/native/unreal.js +4 -6
  44. package/dist/guest/toolchains/native/visual-studio.d.ts +14 -14
  45. package/dist/guest/toolchains/native/visual-studio.js +145 -174
  46. package/dist/guest/toolchains/native/vs-persist.d.ts +189 -0
  47. package/dist/guest/toolchains/native/vs-persist.js +256 -0
  48. package/dist/guest/toolchains/native/vs-request.d.ts +6 -26
  49. package/dist/guest/toolchains/native/vs-request.js +16 -50
  50. package/dist/guest/toolchains/plan.js +11 -14
  51. package/dist/host/migrate/migrations/drop-vs-install-files.d.ts +5 -0
  52. package/dist/host/migrate/migrations/drop-vs-install-files.js +34 -0
  53. package/dist/host/migrate/registry.js +7 -1
  54. package/dist/runtime/agent.js +67 -26
  55. package/dist/runtime/daemon.js +958 -716
  56. package/dist/runtime/db.js +67 -26
  57. package/dist/runtime/main.js +1792 -1038
  58. package/dist/runtime/netproxy.js +3 -6
  59. package/dist/runtime/tool.js +44 -24
  60. package/dist/runtime/view.js +4 -7
  61. package/dist/selftest/agent-paths.test.js +17 -1
  62. package/dist/selftest/agent-settings.test.js +36 -5
  63. package/dist/selftest/boot-console.test.d.ts +2 -0
  64. package/dist/selftest/boot-console.test.js +291 -0
  65. package/dist/selftest/boot-runner.test.js +10 -12
  66. package/dist/selftest/guest-boot.test.js +7 -1
  67. package/dist/selftest/index.js +2 -0
  68. package/dist/selftest/layout.test.js +4 -0
  69. package/dist/selftest/migrate.test.js +21 -0
  70. package/dist/selftest/persisted-installs.test.js +169 -33
  71. package/dist/selftest/support/engine-golden.js +2 -0
  72. package/dist/selftest/support/fixtures.d.ts +1 -2
  73. package/dist/shared/channel/schema.d.ts +3 -6
  74. package/dist/shared/pty/terminal.js +2 -1
  75. package/dist/sync/watcher.js +5 -1
  76. package/native/csharp/HcsHost/AssemblyInfo.cs +3 -3
  77. package/native/csharp/HostTools/AssemblyInfo.cs +3 -3
  78. package/native/csharp/StorageHelper/AssemblyInfo.cs +3 -3
  79. package/package.json +1 -1
  80. package/sandbox/scripts/restore-visual-studio.ps1 +78 -0
  81. package/sandbox/scripts/save-visual-studio.ps1 +106 -0
  82. package/schemas/config.schema.json +3 -7
  83. package/schemas/sandbox-config.schema.json +3 -7
  84. package/dist/guest/toolchains/native/autosdk.d.ts +0 -3
  85. package/dist/guest/toolchains/native/autosdk.js +0 -50
@@ -25697,7 +25697,7 @@ var ToolchainsSchema = external_exports.strictObject({
25697
25697
  }),
25698
25698
  unreal: external_exports.strictObject({
25699
25699
  enabled: enabled(
25700
- "Unreal Engine development: Visual Studio Community 2022 with the C++ game workload (MSVC, a Windows 11 SDK, .NET Framework targeting packs, Unreal integration and the Epic Games Launcher) and the DirectX End-User Runtime. A real installer: it runs every boot (~10 GB on the sandbox disk). The engine itself comes from the Epic Games Launcher or its source.",
25700
+ "Unreal Engine development: Visual Studio Community 2022 with the C++ game workload (MSVC, a Windows 11 SDK, .NET Framework targeting packs, Unreal integration and the Epic Games Launcher) and the DirectX End-User Runtime. Visual Studio is installed once and kept on the data disk (~6.5 GB); later boots put it back in seconds. The engine itself comes from the Epic Games Launcher or its source.",
25701
25701
  "a .uproject or .uplugin"
25702
25702
  ),
25703
25703
  acceptLicense: external_exports.boolean().describe(
@@ -25706,11 +25706,8 @@ var ToolchainsSchema = external_exports.strictObject({
25706
25706
  components: StringsSchema.describe(
25707
25707
  'More Visual Studio workload or component IDs to add, e.g. ["Microsoft.VisualStudio.Component.VC.Llvm.Clang"].'
25708
25708
  ),
25709
- keepInstallFiles: external_exports.boolean().describe(
25710
- "Keep an offline copy of the Visual Studio install files in tools\\visualstudio (~10 GB, made once), so later boots install without downloading them again. Skipped while that disk has too little room."
25711
- ),
25712
- visualStudio: external_exports.enum(["full", "autosdk"]).describe(
25713
- `"full": install Visual Studio Community (the engine finds it through the Setup API; ~10 GB, redone every boot). "autosdk" (experimental): skip Visual Studio and build an Unreal AutoSDK tree from the portable MSVC in tools\\unreal\\autosdk (kept between sandboxes); UnrealBuildTool finds MSVC through AutoSDK, but the editor's Visual Studio integration is not available. Needs toolchains.msvc (turned on for you).`
25709
+ visualStudio: external_exports.enum(["full", "buildtools"]).describe(
25710
+ '"full": Visual Studio Community with the C++ game workload (the IDE, its debugger and Unreal integration). "buildtools": only the Visual Studio Build Tools (MSVC, the Windows SDK and the .NET Framework parts UnrealBuildTool needs; no IDE). Either is installed once and kept on the data disk (tools\\visualstudio\\persist, ~6.5 GB for Community); later boots put it back in seconds.'
25714
25711
  ),
25715
25712
  persistEngines: external_exports.boolean().describe(
25716
25713
  "Engines the Epic Games Launcher installs go to tools\\unreal\\engines (kept between sandboxes, 40-60 GB each) and are registered with the launcher again every boot. Off: they go on the sandbox disk and are lost when it closes. Engines installed on this PC can be mapped instead (pcInstalls.unrealEngines)."
@@ -26070,7 +26070,7 @@ var ToolchainsSchema = external_exports.strictObject({
26070
26070
  }),
26071
26071
  unreal: external_exports.strictObject({
26072
26072
  enabled: enabled(
26073
- "Unreal Engine development: Visual Studio Community 2022 with the C++ game workload (MSVC, a Windows 11 SDK, .NET Framework targeting packs, Unreal integration and the Epic Games Launcher) and the DirectX End-User Runtime. A real installer: it runs every boot (~10 GB on the sandbox disk). The engine itself comes from the Epic Games Launcher or its source.",
26073
+ "Unreal Engine development: Visual Studio Community 2022 with the C++ game workload (MSVC, a Windows 11 SDK, .NET Framework targeting packs, Unreal integration and the Epic Games Launcher) and the DirectX End-User Runtime. Visual Studio is installed once and kept on the data disk (~6.5 GB); later boots put it back in seconds. The engine itself comes from the Epic Games Launcher or its source.",
26074
26074
  "a .uproject or .uplugin"
26075
26075
  ),
26076
26076
  acceptLicense: external_exports.boolean().describe(
@@ -26079,11 +26079,8 @@ var ToolchainsSchema = external_exports.strictObject({
26079
26079
  components: StringsSchema.describe(
26080
26080
  'More Visual Studio workload or component IDs to add, e.g. ["Microsoft.VisualStudio.Component.VC.Llvm.Clang"].'
26081
26081
  ),
26082
- keepInstallFiles: external_exports.boolean().describe(
26083
- "Keep an offline copy of the Visual Studio install files in tools\\visualstudio (~10 GB, made once), so later boots install without downloading them again. Skipped while that disk has too little room."
26084
- ),
26085
- visualStudio: external_exports.enum(["full", "autosdk"]).describe(
26086
- `"full": install Visual Studio Community (the engine finds it through the Setup API; ~10 GB, redone every boot). "autosdk" (experimental): skip Visual Studio and build an Unreal AutoSDK tree from the portable MSVC in tools\\unreal\\autosdk (kept between sandboxes); UnrealBuildTool finds MSVC through AutoSDK, but the editor's Visual Studio integration is not available. Needs toolchains.msvc (turned on for you).`
26082
+ visualStudio: external_exports.enum(["full", "buildtools"]).describe(
26083
+ '"full": Visual Studio Community with the C++ game workload (the IDE, its debugger and Unreal integration). "buildtools": only the Visual Studio Build Tools (MSVC, the Windows SDK and the .NET Framework parts UnrealBuildTool needs; no IDE). Either is installed once and kept on the data disk (tools\\visualstudio\\persist, ~6.5 GB for Community); later boots put it back in seconds.'
26087
26084
  ),
26088
26085
  persistEngines: external_exports.boolean().describe(
26089
26086
  "Engines the Epic Games Launcher installs go to tools\\unreal\\engines (kept between sandboxes, 40-60 GB each) and are registered with the launcher again every boot. Off: they go on the sandbox disk and are lost when it closes. Engines installed on this PC can be mapped instead (pcInstalls.unrealEngines)."
@@ -28127,19 +28124,33 @@ var run = new RunDir(dataDir);
28127
28124
 
28128
28125
  // src/guest/runtime/process.ts
28129
28126
  import { spawn as spawn2, spawnSync } from "node:child_process";
28127
+ var outputSink = null;
28128
+ var captureOutput = () => outputSink?.capturing() ?? false;
28129
+ function logOutput(text) {
28130
+ for (const line of text.split(/\r?\n/)) if (line.trim()) outputSink?.line(line.trimEnd());
28131
+ }
28132
+ var CAPTURE_BYTES = 64 * 1024 * 1024;
28133
+ function scriptErrors(errors) {
28134
+ if (!errors) return;
28135
+ if (captureOutput()) logOutput(errors);
28136
+ else process.stderr.write(errors + "\n");
28137
+ }
28130
28138
  function execOutput(file2, args2, options = {}) {
28131
28139
  const result = spawnSync(file2, args2, { encoding: "utf8", windowsHide: true, ...options });
28132
28140
  return result.status === 0 ? String(result.stdout ?? "").trim() : "";
28133
28141
  }
28134
28142
  function runScript(name2, args2 = []) {
28143
+ const capture = captureOutput();
28135
28144
  const result = spawnSync(SYSTEM.powershell(), scriptArgs(name2, args2), {
28136
- stdio: ["inherit", "inherit", "pipe"],
28145
+ stdio: ["inherit", capture ? "pipe" : "inherit", "pipe"],
28137
28146
  encoding: "utf8",
28138
- windowsHide: true
28147
+ windowsHide: true,
28148
+ ...capture ? { maxBuffer: CAPTURE_BYTES } : {}
28139
28149
  });
28150
+ if (capture) logOutput(String(result.stdout ?? ""));
28140
28151
  if (result.error) throw result.error;
28141
28152
  const errors = String(result.stderr ?? "").trim();
28142
- if (errors) process.stderr.write(errors + "\n");
28153
+ scriptErrors(errors);
28143
28154
  if (result.status !== 0)
28144
28155
  throw new Error(name2 + " failed with exit code " + result.status + (errors ? ": " + errors.slice(0, 400) : ""));
28145
28156
  }
@@ -28250,36 +28261,45 @@ import path17 from "node:path";
28250
28261
  import fs11 from "node:fs";
28251
28262
  import path12 from "node:path";
28252
28263
  import { AsyncLocalStorage } from "node:async_hooks";
28264
+
28265
+ // src/guest/runtime/log-line.ts
28266
+ var ESCAPES = /\x1b\[[0-9;?]{0,32}[A-Za-z]/g;
28267
+ function plainText(text) {
28268
+ return text.replace(ESCAPES, "");
28269
+ }
28270
+ function fileLines(message, at = /* @__PURE__ */ new Date(), task) {
28271
+ const stamp = at.toISOString() + " " + (task ? "[" + task + "] " : "");
28272
+ return plainText(message).split(/\r?\n/).filter((line) => line.trim() !== "").map((line) => stamp + line.trimEnd());
28273
+ }
28274
+
28275
+ // src/guest/runtime/log.ts
28253
28276
  fs11.mkdirSync(logsDir, { recursive: true });
28254
28277
  var logFile = path12.join(
28255
28278
  logsDir,
28256
28279
  (process.env.AGENT_LOG_PREFIX ?? "guest") + "-" + (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-") + ".log"
28257
28280
  );
28258
28281
  var context = new AsyncLocalStorage();
28259
- function fileLine(message, at = /* @__PURE__ */ new Date()) {
28260
- return at.toISOString() + " " + message;
28261
- }
28262
- var append = (line) => {
28282
+ var sink = null;
28283
+ var append = (message, ctx) => {
28263
28284
  try {
28264
- fs11.appendFileSync(logFile, fileLine(line) + "\n");
28285
+ const lines = fileLines(message, /* @__PURE__ */ new Date(), ctx?.task);
28286
+ if (lines.length) fs11.appendFileSync(logFile, lines.join("\n") + "\n");
28265
28287
  } catch {
28266
28288
  }
28267
28289
  };
28268
- var tagged = (message, ctx) => {
28269
- const prefix = ctx?.prefix?.();
28270
- return prefix ? prefix + " | " + message : message;
28271
- };
28272
28290
  function log(message) {
28273
28291
  const ctx = context.getStore();
28274
- const line = tagged(message, ctx);
28275
- if (!ctx?.quiet) console.log(line);
28276
- append(line);
28292
+ append(message, ctx);
28293
+ if (ctx?.quiet) return;
28294
+ if (sink) sink.line(message, ctx?.stage);
28295
+ else console.log(message);
28277
28296
  }
28278
28297
  function warn(message) {
28279
28298
  const ctx = context.getStore();
28280
- const line = tagged(message, ctx);
28281
- if (!ctx?.quiet) console.warn("\x1B[33mWARNING: " + line + "\x1B[0m");
28282
- append("WARNING: " + line);
28299
+ append("WARNING: " + message, ctx);
28300
+ if (ctx?.quiet) return;
28301
+ if (sink) sink.warning(message);
28302
+ else console.warn("\x1B[33mWARNING: " + message + "\x1B[0m");
28283
28303
  }
28284
28304
 
28285
28305
  // src/guest/toolchains/signature.ts
@@ -6176,7 +6176,7 @@ function runTerminalClient(socket, token, options = {}) {
6176
6176
  socket.on("close", () => finish({ reason: "closed" }));
6177
6177
  socket.on("error", () => finish({ reason: "closed" }));
6178
6178
  const [c, r] = size();
6179
- stdout.write("\x1B[2J\x1B[3J\x1B[H");
6179
+ stdout.write("\x1B[2J\x1B[H");
6180
6180
  socket.write(helloFrame(token, c, r));
6181
6181
  if (stdin.isTTY) stdin.setRawMode(true);
6182
6182
  vtInput = enableVtInput();
@@ -26273,7 +26273,7 @@ var ToolchainsSchema = external_exports.strictObject({
26273
26273
  }),
26274
26274
  unreal: external_exports.strictObject({
26275
26275
  enabled: enabled(
26276
- "Unreal Engine development: Visual Studio Community 2022 with the C++ game workload (MSVC, a Windows 11 SDK, .NET Framework targeting packs, Unreal integration and the Epic Games Launcher) and the DirectX End-User Runtime. A real installer: it runs every boot (~10 GB on the sandbox disk). The engine itself comes from the Epic Games Launcher or its source.",
26276
+ "Unreal Engine development: Visual Studio Community 2022 with the C++ game workload (MSVC, a Windows 11 SDK, .NET Framework targeting packs, Unreal integration and the Epic Games Launcher) and the DirectX End-User Runtime. Visual Studio is installed once and kept on the data disk (~6.5 GB); later boots put it back in seconds. The engine itself comes from the Epic Games Launcher or its source.",
26277
26277
  "a .uproject or .uplugin"
26278
26278
  ),
26279
26279
  acceptLicense: external_exports.boolean().describe(
@@ -26282,11 +26282,8 @@ var ToolchainsSchema = external_exports.strictObject({
26282
26282
  components: StringsSchema.describe(
26283
26283
  'More Visual Studio workload or component IDs to add, e.g. ["Microsoft.VisualStudio.Component.VC.Llvm.Clang"].'
26284
26284
  ),
26285
- keepInstallFiles: external_exports.boolean().describe(
26286
- "Keep an offline copy of the Visual Studio install files in tools\\visualstudio (~10 GB, made once), so later boots install without downloading them again. Skipped while that disk has too little room."
26287
- ),
26288
- visualStudio: external_exports.enum(["full", "autosdk"]).describe(
26289
- `"full": install Visual Studio Community (the engine finds it through the Setup API; ~10 GB, redone every boot). "autosdk" (experimental): skip Visual Studio and build an Unreal AutoSDK tree from the portable MSVC in tools\\unreal\\autosdk (kept between sandboxes); UnrealBuildTool finds MSVC through AutoSDK, but the editor's Visual Studio integration is not available. Needs toolchains.msvc (turned on for you).`
26285
+ visualStudio: external_exports.enum(["full", "buildtools"]).describe(
26286
+ '"full": Visual Studio Community with the C++ game workload (the IDE, its debugger and Unreal integration). "buildtools": only the Visual Studio Build Tools (MSVC, the Windows SDK and the .NET Framework parts UnrealBuildTool needs; no IDE). Either is installed once and kept on the data disk (tools\\visualstudio\\persist, ~6.5 GB for Community); later boots put it back in seconds.'
26290
26287
  ),
26291
26288
  persistEngines: external_exports.boolean().describe(
26292
26289
  "Engines the Epic Games Launcher installs go to tools\\unreal\\engines (kept between sandboxes, 40-60 GB each) and are registered with the launcher again every boot. Off: they go on the sandbox disk and are lost when it closes. Engines installed on this PC can be mapped instead (pcInstalls.unrealEngines)."
@@ -6,7 +6,7 @@ import { DEFAULTS, mergeConfig, validateConfig } from '../core/config/index.js';
6
6
  import { isWin } from '../core/util/index.js';
7
7
  import { SYSTEM } from '../shared/system.js';
8
8
  import { agentCommand, agentShims, bootResetPaths, isCopyableDatabaseFile, isDatabaseFile, localStateEntries, shimText, } from '../guest/agents/agent-files.js';
9
- import { appendMissing, expandVars, installedToolPaths, isGitUsrBin, isMsvcToolsDir, isPinnedNodeDir, msvcBeforeGitUsr, parseRegPath, pathKey, planPath, rebuildPath, splitPath, } from '../guest/setup/path-plan.js';
9
+ import { appendMissing, expandVars, installedToolPaths, isGitUsrBin, isMsvcToolsDir, isPinnedNodeDir, msvcBeforeGitUsr, parseRegPath, pathKey, planPath, rebuildPath, sessionPath, splitPath, } from '../guest/setup/path-plan.js';
10
10
  import { withReloadEnvironment } from '../host/tools/index.js';
11
11
  import { HarnessSchema } from '../core/schema/index.js';
12
12
  export const agentPathsSuite = {
@@ -64,6 +64,22 @@ export const agentPathsSuite = {
64
64
  const exists = (f) => present.has(pathKey(f));
65
65
  t.equal(planPath({ entries: ['C:\\A\\;c:\\b', 'C:\\a', 'C:\\missing;"C:\\C\\D"', '', 'c:/B'], exists }), ['C:\\A\\', 'c:\\b', 'C:\\C\\D'], 'ordered, no duplicates (case, slash, quotes), no missing folders');
66
66
  t.equal(planPath({ entries: ['C:\\a;C:\\nvm\\v24.1.0'], exists, drop: (f) => isPinnedNodeDir(f, 'C:\\NVM') }), ['C:\\a'], 'a pinned node version is never machine-wide');
67
+ const agentPath = sessionPath({
68
+ commands: 'C:\\nvm\\bin',
69
+ prepend: ['C:\\AgentData\\tools\\bin', 'C:\\AgentData\\tools\\nvm', 'C:\\nvm\\nodejs'],
70
+ base: ['C:\\Windows\\system32', 'c:\\NVM\\bin\\'],
71
+ append: ['C:\\AgentData\\tools\\agents\\codex'],
72
+ });
73
+ t.equal(agentPath, [
74
+ 'C:\\nvm\\bin',
75
+ 'C:\\AgentData\\tools\\bin',
76
+ 'C:\\AgentData\\tools\\nvm',
77
+ 'C:\\nvm\\nodejs',
78
+ 'C:\\Windows\\system32',
79
+ 'C:\\AgentData\\tools\\agents\\codex',
80
+ ], "the sandbox's agent shims come first, before nvm's node folder (an npm -g codex there) and the agent's own folder");
81
+ t.equal(planPath({ entries: [agentPath.join(';')], exists: () => true })[0], 'C:\\nvm\\bin', 'and stay first on the machine PATH that terminal tabs get');
82
+ t.equal(rebuildPath({ machine: agentPath.join(';'), user: 'C:\\nvm\\nodejs', process: '', env: {} }).split(';')[0], 'C:\\nvm\\bin', 'a new shell keeps them first');
67
83
  t.check(isPinnedNodeDir('C:\\nvm\\v24.1.0\\', 'C:\\nvm') && !isPinnedNodeDir('C:\\nvm', 'C:\\nvm'), 'only versions are pinned');
68
84
  t.equal(pathKey('C:\\'), 'c:\\', 'a drive root keeps its slash');
69
85
  t.equal(splitPath(' "C:\\a" ;; C:\\b;'), ['C:\\a', 'C:\\b'], 'splitting drops empty parts and quotes');
@@ -30,14 +30,45 @@ export const agentSettingsSuite = {
30
30
  t.section('codex config.toml');
31
31
  const old = '# Set by the sandbox: x\nsandbox_mode = "danger-full-access"\n\n[tui]\nscreen_reader_detection_done = true\n\n[windows]\nsandbox = "elevated"\n';
32
32
  const owned = codexConfigOwned(old, CODEX);
33
- t.check(!owned.includes('sandbox_mode') && !owned.includes('Set by the sandbox') && !owned.includes('sandbox = '), 'the old sandbox_mode lines and [windows] sandbox are removed', owned);
34
- t.check(owned.includes('mcp_oauth_credentials_store = "file"') &&
35
- owned.includes('[tui]\nscreen_reader_detection_done = true\n') &&
36
- owned.includes('[windows]'), 'MCP logins go to a file; every other line stays', owned);
33
+ const marker = owned.split('\n')[0];
34
+ t.check(marker.startsWith('# Set by the sandbox'), 'the marked lines start the file', owned);
35
+ t.equal(owned, marker +
36
+ '\nsandbox_mode = "danger-full-access"\napproval_policy = "never"\n\nmcp_oauth_credentials_store = "file"\n\n' +
37
+ '[tui]\nscreen_reader_detection_done = true\n\n[windows]\n', 'sandbox_mode and approval_policy are written above the first table (the old marked lines replaced), MCP logins go to a file, [windows] sandbox is removed for "off", every other line stays');
37
38
  t.equal(codexConfigOwned(owned, CODEX), owned, 'running it again changes nothing');
39
+ t.equal(codexConfigOwned(owned, { ...CODEX, sandboxMode: 'workspace-write', approvalPolicy: 'on-request' }), owned
40
+ .replace('"danger-full-access"', '"workspace-write"')
41
+ .replace('approval_policy = "never"', 'approval_policy = "on-request"'), 'changed settings replace the marked lines in place');
42
+ const tuned = codexConfigOwned('', { ...CODEX, model: 'gpt-5', reasoningEffort: 'high', mcpLoginsInFile: false });
43
+ t.equal(tuned.split('\n').slice(1), [
44
+ 'sandbox_mode = "danger-full-access"',
45
+ 'approval_policy = "never"',
46
+ 'model = "gpt-5"',
47
+ 'model_reasoning_effort = "high"',
48
+ '',
49
+ ], 'model and reasoning effort are written when set (an empty file gets only the marked lines)');
50
+ t.equal(codexConfigOwned(tuned, { ...CODEX, mcpLoginsInFile: false }), tuned.replace('model = "gpt-5"\nmodel_reasoning_effort = "high"\n', ''), 'and removed again when cleared');
51
+ const mine = 'approval_policy = "on-request"\n\n[windows]\nsandbox = "elevated"\n';
52
+ const respected = codexConfigOwned(mine, { ...CODEX, mcpLoginsInFile: false });
53
+ t.check(respected.includes('sandbox_mode = "danger-full-access"') &&
54
+ respected.includes('approval_policy = "on-request"') &&
55
+ !respected.includes('approval_policy = "never"') &&
56
+ !respected.includes('sandbox = "elevated"'), "a user's own unmarked line is kept and not written again", respected);
57
+ t.equal(codexConfigOwned(respected, { ...CODEX, mcpLoginsInFile: false }), respected, 'stable with it too');
58
+ const crlf = old.replace(/\n/g, '\r\n');
59
+ const ownedCrlf = codexConfigOwned(crlf, CODEX);
60
+ t.equal(ownedCrlf, owned.replace(/\n/g, '\r\n'), 'a CRLF file is edited the same way and keeps CRLF');
61
+ t.equal(codexConfigOwned(ownedCrlf, CODEX), ownedCrlf, 'running it again on CRLF changes nothing');
62
+ const byHand = '# agent-sandbox: Windows Sandbox already contains Codex; its own command sandbox is off.\n' +
63
+ 'sandbox_mode = "danger-full-access"\napproval_policy = "never"\nmcp_oauth_credentials_store = "file"\n\n' +
64
+ '[tui]\nscreen_reader_detection_done = true\n\n[projects.\'c:\\x\']\ntrust_level = "trusted"\n';
65
+ const adopted = codexConfigOwned(byHand, CODEX);
66
+ t.equal(adopted, marker +
67
+ '\nsandbox_mode = "danger-full-access"\napproval_policy = "never"\n\nmcp_oauth_credentials_store = "file"\n\n' +
68
+ '[tui]\nscreen_reader_detection_done = true\n\n[projects.\'c:\\x\']\ntrust_level = "trusted"\n', 'lines under the hand-made "# agent-sandbox:" marker count as the sandbox\'s and are replaced');
69
+ t.equal(codexConfigOwned(adopted, CODEX), adopted, 'and stay stable after that');
38
70
  t.includes(codexConfigOwned(old, { ...CODEX, windowsSandbox: 'unelevated' }), '[windows]\nsandbox = "unelevated"', 'a Windows sandbox mode is written when asked for');
39
71
  t.includes(codexConfigOwned('mcp_oauth_credentials_store = "keyring"\n', CODEX), 'mcp_oauth_credentials_store = "keyring"', "a user's own mcp_oauth_credentials_store is kept");
40
- t.equal(codexConfigOwned('', { ...CODEX, mcpLoginsInFile: false }), '', 'an empty file stays empty when nothing is owned');
41
72
  t.section('Anthropic key pattern');
42
73
  const body = 'a'.repeat(60);
43
74
  t.check(ANTHROPIC_KEY_PATTERN.test('sk-ant-usr01-' + body) && ANTHROPIC_KEY_PATTERN.test('sk-ant-api03-' + body), 'sk-ant-api03- and sk-ant-usr01- keys are accepted');
@@ -0,0 +1,2 @@
1
+ import type { Suite } from './support/suite.js';
2
+ export declare const bootConsoleSuite: Suite;
@@ -0,0 +1,291 @@
1
+ // The boot's console (docs/design/parallel-boot.md, "Console output"): a fixture of runner events played into the
2
+ // progress view, read back as the exact plain output (logs, CI, dry runs) and as live frames (a terminal), colours
3
+ // stripped. Also the log file's line format. Pure: a fake clock and a fake screen, no terminal.
4
+ import { plainText, fileLines } from '../guest/runtime/log-line.js';
5
+ import { CONSOLE_GLYPHS, ProgressView, readyLines, RICH_GLYPHS, stepsDetail, wrap, } from '../guest/boot/progress-view.js';
6
+ const STAGES = [
7
+ { stage: 1, name: 'Network and session host' },
8
+ { stage: 2, name: 'Pre-provision hooks and desktop' },
9
+ { stage: 3, name: 'Developer tools' },
10
+ { stage: 4, name: 'Agent: claude' },
11
+ ];
12
+ const TASKS = [
13
+ { id: 'ptyhost', stage: 1, title: 'Session host' },
14
+ { id: 'network', stage: 1, title: 'Private-network blocking' },
15
+ { id: 'desktop', stage: 2, title: 'Personalisation' },
16
+ { id: 'toolchains', stage: 3, title: 'Developer tools', critical: true },
17
+ { id: 'dbCommands', stage: 3, title: 'Tool commands' },
18
+ { id: 'agent', stage: 4, title: 'Agent install', critical: true },
19
+ ];
20
+ const task = (id) => TASKS.find((t) => t.id === id);
21
+ const done = (id, ms) => ({ id, title: task(id).title, state: 'done', ms });
22
+ const play = (live, columns = 80) => {
23
+ let now = 0;
24
+ let written = '';
25
+ let taken = 0;
26
+ const view = new ProgressView(STAGES, TASKS, {
27
+ live,
28
+ glyphs: live ? RICH_GLYPHS : CONSOLE_GLYPHS,
29
+ now: () => now,
30
+ screen: { write: (text) => void (written += text), columns: () => columns },
31
+ });
32
+ return {
33
+ view,
34
+ out: () => written,
35
+ take: () => {
36
+ const part = written.slice(taken);
37
+ taken = written.length;
38
+ return part;
39
+ },
40
+ at: (ms) => {
41
+ now = ms;
42
+ },
43
+ };
44
+ };
45
+ /** The happy boot: overlapping tasks, lines from several stages, a warning, a soft failure, the summary. */
46
+ const happyBoot = (p) => {
47
+ const v = p.view;
48
+ v.start('Agent sandbox · demo · claude · C:\\Work\\demo');
49
+ v.line('Service sign-ins: none', undefined);
50
+ v.started(task('ptyhost'));
51
+ v.started(task('desktop'));
52
+ v.started(task('dbCommands'));
53
+ v.line('Desktop personalisation applied from this PC.', 2);
54
+ p.at(300);
55
+ v.finished(task('ptyhost'), done('ptyhost', 300));
56
+ v.started(task('network'));
57
+ p.at(1200);
58
+ v.finished(task('desktop'), done('desktop', 1200));
59
+ v.line('LAN blocked in the guest firewall.', 1);
60
+ v.warning('gh: the GitHub login was kept in Credential Manager. Log in again with: gh auth login --web');
61
+ p.at(1300);
62
+ v.finished(task('dbCommands'), { ...done('dbCommands', 1300), state: 'failed', error: 'disk full' });
63
+ v.warning('Tool commands failed (continuing): disk full');
64
+ p.at(4600);
65
+ v.finished(task('network'), done('network', 4300));
66
+ v.started(task('toolchains'));
67
+ v.detail('toolchains', stepsDetail(['Node', 'Python', 'Rust', 'Go', 'Java'], 5, 13));
68
+ v.line('Installing the Visual C++ runtime ...', 3);
69
+ p.at(13600);
70
+ v.finished(task('toolchains'), done('toolchains', 9000));
71
+ v.started(task('agent'));
72
+ v.line('claude already cached in persistent storage.', 4);
73
+ p.at(15400);
74
+ v.finished(task('agent'), done('agent', 1800));
75
+ v.summary(['git: 2.43.0.windows.1', 'node: 24.22.0 (from this PC, switch with nvm use)', 'vc++ runtime: installed']);
76
+ v.notice('If Claude asks you to sign in, do it once; the login is saved in persistent storage.');
77
+ v.release({
78
+ ms: 15400,
79
+ outcomes: [done('ptyhost', 300), done('network', 4300), done('toolchains', 9000), done('agent', 1800)],
80
+ background: ['Visual Studio', 'Unreal'],
81
+ logPath: 'C:\\AgentData\\logs\\guest.log',
82
+ });
83
+ };
84
+ const PLAIN_HAPPY = [
85
+ 'Agent sandbox · demo · claude · C:\\Work\\demo',
86
+ '',
87
+ 'Service sign-ins: none',
88
+ '[1/4] Network and session host 4.6s',
89
+ ' LAN blocked in the guest firewall.',
90
+ '[2/4] Pre-provision hooks and desktop 1.2s',
91
+ ' Desktop personalisation applied from this PC.',
92
+ '[3/4] Developer tools 13.6s',
93
+ ' Installing the Visual C++ runtime ...',
94
+ '[4/4] Agent: claude 1.8s',
95
+ ' claude already cached in persistent storage.',
96
+ '',
97
+ 'Warnings',
98
+ ' ! gh: the GitHub login was kept in Credential Manager. Log in again with: gh auth login --web',
99
+ ' ! Tool commands failed (continuing): disk full',
100
+ '',
101
+ 'Tools',
102
+ ' git 2.43.0.windows.1',
103
+ ' node 24.22.0 (from this PC, switch with nvm use)',
104
+ ' vc++ runtime installed',
105
+ '',
106
+ 'If Claude asks you to sign in, do it once; the login is saved in persistent storage.',
107
+ '',
108
+ 'Ready in 15.4s (slowest: Developer tools 9.0s, Private-network blocking 4.3s, Agent install 1.8s)',
109
+ 'Still installing in the background: Visual Studio, Unreal (a few minutes; it does not hold you up)',
110
+ 'Log: C:\\AgentData\\logs\\guest.log',
111
+ '',
112
+ ].join('\n');
113
+ export const bootConsoleSuite = {
114
+ name: 'boot-console',
115
+ title: "the boot's console: plain ordered output, live stage rows, warnings, tool summary, log file lines",
116
+ run: (t) => {
117
+ t.section('plain output (logs, CI, dry runs)');
118
+ {
119
+ const p = play(false);
120
+ happyBoot(p);
121
+ t.equal(p.out().split('\n'), PLAIN_HAPPY.split('\n'), 'a boot prints its stages in order, each once finished');
122
+ t.check(!p.out().includes('\x1b'), 'plain output has no colour or cursor codes');
123
+ }
124
+ {
125
+ // Stage 1 finishes last: stages 2 and 3 wait for it, then all three come out in order.
126
+ const p = play(false);
127
+ const v = p.view;
128
+ v.start('header');
129
+ p.take();
130
+ v.started(task('ptyhost'));
131
+ v.started(task('desktop'));
132
+ v.finished(task('desktop'), done('desktop', 0));
133
+ const early = p.take();
134
+ v.finished(task('ptyhost'), done('ptyhost', 0));
135
+ v.started(task('network'));
136
+ v.finished(task('network'), done('network', 0));
137
+ t.check(early === '' && /^\[1\/4\][^\n]*\n\[2\/4\]/.test(p.take()), 'a finished stage waits until the stages before it are printed', early);
138
+ }
139
+ {
140
+ const p = play(false);
141
+ const v = p.view;
142
+ v.start('header');
143
+ v.started(task('ptyhost'));
144
+ v.finished(task('ptyhost'), done('ptyhost', 100));
145
+ v.started(task('network'));
146
+ v.finished(task('network'), done('network', 100));
147
+ v.started(task('desktop'));
148
+ v.finished(task('desktop'), done('desktop', 100));
149
+ v.started(task('dbCommands'));
150
+ v.started(task('toolchains'));
151
+ p.at(2000);
152
+ v.finished(task('toolchains'), { ...done('toolchains', 2000), state: 'failed', error: 'no network' });
153
+ v.finished(task('dbCommands'), done('dbCommands', 2000));
154
+ v.finished(task('agent'), { ...done('agent', 0), state: 'skipped', error: 'Developer tools failed' });
155
+ p.take();
156
+ v.release(null);
157
+ t.equal(p.out().split('\n').slice(4, 8), [
158
+ '[3/4] Developer tools 2.0s FAILED',
159
+ ' x Developer tools failed: no network',
160
+ '[4/4] Agent: claude skipped',
161
+ ' - Agent install skipped (Developer tools failed)',
162
+ ], 'a critical failure marks its stage FAILED and says why; what comes after it is skipped');
163
+ }
164
+ {
165
+ const p = play(false);
166
+ p.view.start('header');
167
+ p.view.started(task('ptyhost'));
168
+ p.view.line('half way', 1);
169
+ p.take();
170
+ p.view.release(null);
171
+ t.equal(p.take().split('\n'), ['[1/4] Network and session host 0.0s not finished', ' half way', ''], 'when the boot fails, a stage that had started is printed with what it logged');
172
+ }
173
+ t.section('live frames (a terminal)');
174
+ {
175
+ const p = play(true);
176
+ const v = p.view;
177
+ v.start('Agent sandbox · demo · claude · C:\\Work\\demo');
178
+ t.equal(v.frame(false), [
179
+ ' · 1 Network and session host waiting',
180
+ ' · 2 Pre-provision hooks and desktop waiting',
181
+ ' · 3 Developer tools waiting',
182
+ ' · 4 Agent: claude waiting',
183
+ ], 'before any task: every stage waits');
184
+ v.started(task('ptyhost'));
185
+ v.started(task('desktop'));
186
+ v.started(task('dbCommands'));
187
+ p.at(300);
188
+ v.finished(task('ptyhost'), done('ptyhost', 300));
189
+ v.started(task('network'));
190
+ p.at(1200);
191
+ v.finished(task('desktop'), done('desktop', 1200));
192
+ v.finished(task('dbCommands'), done('dbCommands', 1200));
193
+ v.started(task('toolchains'));
194
+ v.detail('toolchains', stepsDetail(['Node', 'Python', 'Rust', 'Go', 'Java'], 5, 13));
195
+ v.warning('gh: the GitHub login was kept in Credential Manager. Log in again with: gh auth login --web');
196
+ p.at(12340);
197
+ t.equal(v.frame(false), [
198
+ // 12340 ms: the spinner's fifth frame.
199
+ ' ⠼ 1 Network and session host 12.3s Private-network blocking',
200
+ ' ✓ 2 Pre-provision hooks and desktop 1.2s',
201
+ ' ⠼ 3 Developer tools 12.3s Node, Python, Rust +2 (5/13)',
202
+ ' · 4 Agent: claude waiting',
203
+ '',
204
+ ' ! gh: the GitHub login was kept in Credential Manager. Log in again with: gh…',
205
+ ], 'while tasks run: their stage spins and names them (the toolchain steps), done stages show their time');
206
+ t.check(v.frame(false).every((l) => l.length <= 79) && plainText(v.frame().join('\n')) === v.frame(false).join('\n'), 'every row fits in the terminal (one column spare); the colours are only colours');
207
+ const narrow = play(true, 53);
208
+ narrow.view.started(task('toolchains'));
209
+ narrow.view.detail('toolchains', stepsDetail(['Node', 'Python'], 1, 9));
210
+ t.equal(narrow.view.frame(false)[2], ' ⠋ 3 Developer tools 0.0s Nod…', 'a narrow terminal cuts the row, not wraps it');
211
+ p.take();
212
+ v.tick();
213
+ const redraw = p.take();
214
+ // The first frame (at start) had the 4 rows; this one has the rows, a blank line and the warning.
215
+ t.check(redraw.startsWith('\r\x1b[4A') && redraw.endsWith('\x1b[J') && redraw.split('\n').length === 7, 'a redraw moves up over the block it drew last and draws the new one in place', JSON.stringify(redraw.slice(0, 40)));
216
+ v.tick();
217
+ t.check(p.take() === '', 'nothing is drawn when nothing changed');
218
+ const before = v.capturing;
219
+ let inside = true;
220
+ const printed = v.aside(() => {
221
+ inside = v.capturing;
222
+ v.line('Hook [pre-agent]: 90-games.cmd', 2);
223
+ return 7;
224
+ });
225
+ const asideText = p.take();
226
+ t.check(printed === 7 &&
227
+ asideText.startsWith('\r\x1b[6A\x1b[J') &&
228
+ asideText.includes('Hook [pre-agent]: 90-games.cmd\n') &&
229
+ asideText.endsWith('\x1b[J'), 'a hook gets the terminal: the block steps aside, the hook prints, the block comes back below it', JSON.stringify(asideText.slice(0, 60)));
230
+ t.check(before && !inside && v.capturing, "programs' output goes to the log while the block owns the terminal, not while a hook has it");
231
+ v.finished(task('network'), done('network', 12040));
232
+ v.finished(task('toolchains'), done('toolchains', 11140));
233
+ v.started(task('agent'));
234
+ p.at(14000);
235
+ v.finished(task('agent'), done('agent', 1660));
236
+ v.summary(['git: 2.43.0.windows.1', 'PROTECTION MISSING: the LAN firewall is off']);
237
+ p.take();
238
+ v.release({
239
+ ms: 14000,
240
+ outcomes: [done('network', 12040), done('toolchains', 11140), done('agent', 1660)],
241
+ background: [],
242
+ logPath: 'C:\\AgentData\\logs\\guest.log',
243
+ });
244
+ const final = p.take();
245
+ t.equal(plainText(final).replace(/\r/g, '').split('\n'), [
246
+ ' ✓ 1 Network and session host 12.3s',
247
+ ' ✓ 2 Pre-provision hooks and desktop 1.2s',
248
+ ' ✓ 3 Developer tools 12.3s',
249
+ ' ✓ 4 Agent: claude 1.7s',
250
+ '',
251
+ 'Warnings',
252
+ ' ! gh: the GitHub login was kept in Credential Manager. Log in again with: gh',
253
+ ' auth login --web',
254
+ '',
255
+ 'Tools',
256
+ ' git 2.43.0.windows.1',
257
+ ' PROTECTION MISSING the LAN firewall is off',
258
+ '',
259
+ 'Ready in 14.0s (slowest: Private-network blocking 12.0s, Developer tools 11.1s, Agent install 1.7s)',
260
+ 'Log: C:\\AgentData\\logs\\guest.log',
261
+ '',
262
+ ], 'when ready: the block stays as a still list, then the warnings in full, the tools and the ready line');
263
+ t.check(final.startsWith('\r\x1b[') && final.endsWith('\x1b[?25h') && !v.capturing, 'release erases the live block, shows the cursor again and gives the console back');
264
+ v.line('late line', 3);
265
+ v.warning('late warning');
266
+ v.tick();
267
+ t.check(p.take() === '', 'nothing prints after the release (the first session owns the console)');
268
+ }
269
+ {
270
+ const p = play(true);
271
+ p.view.start('x');
272
+ p.view.started(task('ptyhost'));
273
+ p.view.line('an info line', 1);
274
+ t.check(!plainText(p.out()).includes('an info line') && p.view.capturing, 'live: info lines go to the log file only, and programs hand their output to the log');
275
+ }
276
+ t.section('pieces');
277
+ t.equal(stepsDetail([], 0, 0), 'Developer tools', 'no step running yet: the stage says what it does');
278
+ t.equal(readyLines({ ms: 900, outcomes: [done('ptyhost', 900)], background: ['A', 'B', 'C', 'D', 'E'], logPath: 'l' }), [
279
+ 'Ready in 0.9s',
280
+ 'Still installing in the background: A, B, C and 2 more (a few minutes; it does not hold you up)',
281
+ ], 'the ready line names no task under a second; a long background list is shortened');
282
+ t.equal(wrap('aa bb cc dd', 5), ['aa bb', 'cc dd'], 'wrap breaks between words');
283
+ t.equal(wrap('abcdefghij k', 4), ['abcd', 'efgh', 'ij k'], 'wrap cuts a word longer than the width');
284
+ t.section('log file lines');
285
+ const at = new Date(Date.UTC(2026, 10, 4, 2, 7, 5, 9));
286
+ t.equal(fileLines('hello', at), ['2026-11-04T02:07:05.009Z hello'], 'a file line starts with a zero-padded UTC time');
287
+ t.equal(fileLines('\n\x1b[36m[1/9] Network\x1b[0m\r\nsecond ', at, 'Developer tools'), ['2026-11-04T02:07:05.009Z [Developer tools] [1/9] Network', '2026-11-04T02:07:05.009Z [Developer tools] second'], 'colours are stripped, each line gets its own time and task, blank lines are dropped');
288
+ t.equal(fileLines('\n \n', at), [], 'a blank message writes nothing');
289
+ t.equal(plainText('\x1b[2K\x1b[?25l\x1b[1;33mx\x1b[0m'), 'x', 'cursor and colour codes are removed');
290
+ },
291
+ };
@@ -1,6 +1,6 @@
1
1
  // The parallel boot (docs/design/parallel-boot.md): the task runner, the pools, the boot graph and the toolchain
2
2
  // scheduler's download phase and background split. Fake tasks and deferred promises only: no machine state.
3
- import { BootRunner, bootSummary, checkGraph } from '../guest/boot/runner.js';
3
+ import { BootRunner, checkGraph, duration } from '../guest/boot/runner.js';
4
4
  import { BOOT_TASKS, STAGES, stageTitle } from '../guest/boot/graph.js';
5
5
  import { backgroundNames, mergeParts, runSplit } from '../guest/toolchains/split.js';
6
6
  import { StepScheduler } from '../guest/toolchains/step-scheduler.js';
@@ -31,13 +31,19 @@ const def = (id, after = [], more = {}) => ({
31
31
  const fixture = () => {
32
32
  const lines = [];
33
33
  const warnings = [];
34
+ const events = [];
34
35
  const contexts = [];
35
36
  return {
36
37
  lines,
37
38
  warnings,
39
+ events,
38
40
  contexts,
39
41
  options: (extra = {}) => ({
40
- say: (line) => void lines.push(line),
42
+ trace: (line) => void lines.push(line),
43
+ observer: {
44
+ started: (task) => void events.push('started ' + task.id),
45
+ finished: (task, outcome) => void events.push('finished ' + task.id + ' ' + outcome.state),
46
+ },
41
47
  warn: (line) => void warnings.push(line),
42
48
  now: () => Date.now(),
43
49
  dryRun: false,
@@ -243,7 +249,7 @@ export const bootRunnerSuite = {
243
249
  const quietBefore = f.contexts.map((c) => c.quiet).join(',');
244
250
  await runner.run('background');
245
251
  t.check(before === 'a' && quietBefore === 'false' && ran.join(',') === 'a,bg' && f.contexts[1]?.quiet === true, 'a background task starts only on run("background"), in a quiet (file-only) log context', before + ' / ' + quietBefore);
246
- t.check(!f.lines.some((l) => l.includes('BG: started')), 'background tasks print no progress lines');
252
+ t.check(!f.events.some((e) => e.includes(' bg')) && f.events.join(',') === 'started a,finished a done', 'the progress view hears only the foreground tasks (the background ones are file-only)', f.events.join(','));
247
253
  }
248
254
  t.section('runner: graph checks');
249
255
  t.check(throws(() => checkGraph([def('a', ['b']), def('b', ['a'])])).includes('circle'), 'a cycle is refused');
@@ -347,16 +353,8 @@ export const bootRunnerSuite = {
347
353
  [1, 2, 3, 4, 5, 6, 7, 8, 9].every((n) => stageTitle(n).length > 0) &&
348
354
  stageTitle(4, { vpnServer: 'x' }).includes('x') &&
349
355
  stageTitle(4) === 'VPN (off)', 'there are nine stage titles, the VPN one names the server or says off');
350
- const summary = bootSummary(31800, [
351
- { id: 'a', title: 'Headroom', state: 'done', ms: 28100 },
352
- { id: 'b', title: 'Developer tools', state: 'done', ms: 7000 },
353
- { id: 'c', title: 'Skipped one', state: 'skipped', ms: 0 },
354
- ], ['Visual Studio'], 'guest.log');
355
- t.check(summary.startsWith('Boot ready in 31.8s.') &&
356
- summary.includes('Headroom 28.1s') &&
357
- !summary.includes('Skipped one') &&
358
- summary.includes('In the background: Visual Studio'), 'the boot summary names the time, the slowest tasks and the background work', summary);
359
356
  }
357
+ t.equal([duration(0), duration(4630), duration(59949), duration(60000), duration(422400)], ['0.0s', '4.6s', '59.9s', '1m00s', '7m02s'], 'durations: tenths of a second under a minute, then minutes and seconds');
360
358
  t.section('toolchain scheduler');
361
359
  {
362
360
  const events = [];