movehat 0.2.1 → 0.2.3

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 (176) hide show
  1. package/dist/__tests__/deployContract.test.js +56 -47
  2. package/dist/__tests__/deployContract.test.js.map +1 -1
  3. package/dist/__tests__/exports.test.d.ts +2 -0
  4. package/dist/__tests__/exports.test.d.ts.map +1 -0
  5. package/dist/__tests__/exports.test.js +30 -0
  6. package/dist/__tests__/exports.test.js.map +1 -0
  7. package/dist/__tests__/fixtures/sigint-deploy-harness.d.ts +4 -3
  8. package/dist/__tests__/fixtures/sigint-deploy-harness.d.ts.map +1 -1
  9. package/dist/__tests__/fixtures/sigint-deploy-harness.js +8 -7
  10. package/dist/__tests__/fixtures/sigint-deploy-harness.js.map +1 -1
  11. package/dist/__tests__/fork/api.test.js +5 -0
  12. package/dist/__tests__/fork/api.test.js.map +1 -1
  13. package/dist/__tests__/fork/api.timeout.test.d.ts +2 -0
  14. package/dist/__tests__/fork/api.timeout.test.d.ts.map +1 -0
  15. package/dist/__tests__/fork/api.timeout.test.js +98 -0
  16. package/dist/__tests__/fork/api.timeout.test.js.map +1 -0
  17. package/dist/cli.js +4 -0
  18. package/dist/cli.js.map +1 -1
  19. package/dist/commands/__tests__/compile.toml-mutation.test.d.ts +2 -0
  20. package/dist/commands/__tests__/compile.toml-mutation.test.d.ts.map +1 -0
  21. package/dist/commands/__tests__/compile.toml-mutation.test.js +69 -0
  22. package/dist/commands/__tests__/compile.toml-mutation.test.js.map +1 -0
  23. package/dist/commands/__tests__/init.test.js +73 -11
  24. package/dist/commands/__tests__/init.test.js.map +1 -1
  25. package/dist/commands/compile.d.ts.map +1 -1
  26. package/dist/commands/compile.js +19 -10
  27. package/dist/commands/compile.js.map +1 -1
  28. package/dist/commands/init.d.ts +22 -0
  29. package/dist/commands/init.d.ts.map +1 -1
  30. package/dist/commands/init.js +55 -6
  31. package/dist/commands/init.js.map +1 -1
  32. package/dist/commands/test.js +12 -19
  33. package/dist/commands/test.js.map +1 -1
  34. package/dist/core/AccountManager.d.ts.map +1 -1
  35. package/dist/core/AccountManager.js +14 -2
  36. package/dist/core/AccountManager.js.map +1 -1
  37. package/dist/core/Publisher.d.ts.map +1 -1
  38. package/dist/core/Publisher.js +72 -82
  39. package/dist/core/Publisher.js.map +1 -1
  40. package/dist/core/__tests__/AccountManager.global-state.test.d.ts +2 -0
  41. package/dist/core/__tests__/AccountManager.global-state.test.d.ts.map +1 -0
  42. package/dist/core/__tests__/AccountManager.global-state.test.js +69 -0
  43. package/dist/core/__tests__/AccountManager.global-state.test.js.map +1 -0
  44. package/dist/core/__tests__/movementProfile.test.d.ts +2 -0
  45. package/dist/core/__tests__/movementProfile.test.d.ts.map +1 -0
  46. package/dist/core/__tests__/movementProfile.test.js +112 -0
  47. package/dist/core/__tests__/movementProfile.test.js.map +1 -0
  48. package/dist/core/config.d.ts.map +1 -1
  49. package/dist/core/config.js +14 -10
  50. package/dist/core/config.js.map +1 -1
  51. package/dist/core/deployments.d.ts.map +1 -1
  52. package/dist/core/deployments.js +4 -2
  53. package/dist/core/deployments.js.map +1 -1
  54. package/dist/core/movementProfile.d.ts +55 -22
  55. package/dist/core/movementProfile.d.ts.map +1 -1
  56. package/dist/core/movementProfile.js +77 -99
  57. package/dist/core/movementProfile.js.map +1 -1
  58. package/dist/fork/__tests__/server.cors.test.d.ts +2 -0
  59. package/dist/fork/__tests__/server.cors.test.d.ts.map +1 -0
  60. package/dist/fork/__tests__/server.cors.test.js +79 -0
  61. package/dist/fork/__tests__/server.cors.test.js.map +1 -0
  62. package/dist/fork/api.d.ts +9 -1
  63. package/dist/fork/api.d.ts.map +1 -1
  64. package/dist/fork/api.js +37 -7
  65. package/dist/fork/api.js.map +1 -1
  66. package/dist/fork/manager.js +10 -10
  67. package/dist/fork/manager.js.map +1 -1
  68. package/dist/fork/server.d.ts +20 -1
  69. package/dist/fork/server.d.ts.map +1 -1
  70. package/dist/fork/server.js +40 -24
  71. package/dist/fork/server.js.map +1 -1
  72. package/dist/fork/test.d.ts.map +1 -1
  73. package/dist/fork/test.js +3 -2
  74. package/dist/fork/test.js.map +1 -1
  75. package/dist/harness/Harness.d.ts +6 -2
  76. package/dist/harness/Harness.d.ts.map +1 -1
  77. package/dist/harness/Harness.js +8 -2
  78. package/dist/harness/Harness.js.map +1 -1
  79. package/dist/harness/codeObject.d.ts.map +1 -1
  80. package/dist/harness/codeObject.js +41 -41
  81. package/dist/harness/codeObject.js.map +1 -1
  82. package/dist/harness/script.d.ts +3 -3
  83. package/dist/harness/script.d.ts.map +1 -1
  84. package/dist/harness/script.js +42 -35
  85. package/dist/harness/script.js.map +1 -1
  86. package/dist/helpers/__tests__/setupLocalTesting.fork-network.test.d.ts +2 -0
  87. package/dist/helpers/__tests__/setupLocalTesting.fork-network.test.d.ts.map +1 -0
  88. package/dist/helpers/__tests__/setupLocalTesting.fork-network.test.js +172 -0
  89. package/dist/helpers/__tests__/setupLocalTesting.fork-network.test.js.map +1 -0
  90. package/dist/helpers/setupLocalTesting.d.ts.map +1 -1
  91. package/dist/helpers/setupLocalTesting.js +31 -5
  92. package/dist/helpers/setupLocalTesting.js.map +1 -1
  93. package/dist/index.d.ts +1 -0
  94. package/dist/index.d.ts.map +1 -1
  95. package/dist/node/LocalNodeManager.d.ts +8 -0
  96. package/dist/node/LocalNodeManager.d.ts.map +1 -1
  97. package/dist/node/LocalNodeManager.js +70 -23
  98. package/dist/node/LocalNodeManager.js.map +1 -1
  99. package/dist/node/__tests__/LocalNodeManager.api-port.test.d.ts +2 -0
  100. package/dist/node/__tests__/LocalNodeManager.api-port.test.d.ts.map +1 -0
  101. package/dist/node/__tests__/LocalNodeManager.api-port.test.js +55 -0
  102. package/dist/node/__tests__/LocalNodeManager.api-port.test.js.map +1 -0
  103. package/dist/node/__tests__/LocalNodeManager.test.js +114 -14
  104. package/dist/node/__tests__/LocalNodeManager.test.js.map +1 -1
  105. package/dist/templates/move/Move.toml +1 -1
  106. package/dist/templates/move/sources/Counter.move +31 -4
  107. package/dist/templates/scripts/deploy-counter.ts +10 -0
  108. package/dist/types/config.d.ts +8 -1
  109. package/dist/types/config.d.ts.map +1 -1
  110. package/dist/ui/__tests__/logger.test.d.ts +2 -0
  111. package/dist/ui/__tests__/logger.test.d.ts.map +1 -0
  112. package/dist/ui/__tests__/logger.test.js +75 -0
  113. package/dist/ui/__tests__/logger.test.js.map +1 -0
  114. package/dist/ui/formatters.d.ts +0 -16
  115. package/dist/ui/formatters.d.ts.map +1 -1
  116. package/dist/ui/formatters.js +1 -1
  117. package/dist/ui/formatters.js.map +1 -1
  118. package/dist/ui/logger.d.ts +41 -0
  119. package/dist/ui/logger.d.ts.map +1 -1
  120. package/dist/ui/logger.js +49 -0
  121. package/dist/ui/logger.js.map +1 -1
  122. package/dist/ui/spinner.d.ts +25 -0
  123. package/dist/ui/spinner.d.ts.map +1 -1
  124. package/dist/ui/spinner.js +44 -0
  125. package/dist/ui/spinner.js.map +1 -1
  126. package/dist/utils/__tests__/childProcessAdapter.maxBuffer.test.d.ts +2 -0
  127. package/dist/utils/__tests__/childProcessAdapter.maxBuffer.test.d.ts.map +1 -0
  128. package/dist/utils/__tests__/childProcessAdapter.maxBuffer.test.js +43 -0
  129. package/dist/utils/__tests__/childProcessAdapter.maxBuffer.test.js.map +1 -0
  130. package/dist/utils/childProcessAdapter.d.ts +7 -0
  131. package/dist/utils/childProcessAdapter.d.ts.map +1 -1
  132. package/dist/utils/childProcessAdapter.js +20 -2
  133. package/dist/utils/childProcessAdapter.js.map +1 -1
  134. package/package.json +1 -1
  135. package/src/__tests__/deployContract.test.ts +59 -50
  136. package/src/__tests__/exports.test.ts +32 -0
  137. package/src/__tests__/fixtures/sigint-deploy-harness.ts +8 -7
  138. package/src/__tests__/fork/api.test.ts +5 -0
  139. package/src/__tests__/fork/api.timeout.test.ts +150 -0
  140. package/src/cli.ts +4 -0
  141. package/src/commands/__tests__/compile.toml-mutation.test.ts +77 -0
  142. package/src/commands/__tests__/init.test.ts +96 -11
  143. package/src/commands/compile.ts +24 -15
  144. package/src/commands/init.ts +77 -6
  145. package/src/commands/test.ts +12 -19
  146. package/src/core/AccountManager.ts +18 -1
  147. package/src/core/Publisher.ts +103 -107
  148. package/src/core/__tests__/AccountManager.global-state.test.ts +83 -0
  149. package/src/core/__tests__/movementProfile.test.ts +131 -0
  150. package/src/core/config.ts +18 -11
  151. package/src/core/deployments.ts +5 -4
  152. package/src/core/movementProfile.ts +75 -127
  153. package/src/fork/__tests__/server.cors.test.ts +101 -0
  154. package/src/fork/api.ts +69 -10
  155. package/src/fork/manager.ts +10 -10
  156. package/src/fork/server.ts +59 -24
  157. package/src/fork/test.ts +3 -2
  158. package/src/harness/Harness.ts +11 -2
  159. package/src/harness/codeObject.ts +45 -48
  160. package/src/harness/script.ts +47 -43
  161. package/src/helpers/__tests__/setupLocalTesting.fork-network.test.ts +212 -0
  162. package/src/helpers/setupLocalTesting.ts +39 -5
  163. package/src/index.ts +9 -1
  164. package/src/node/LocalNodeManager.ts +87 -26
  165. package/src/node/__tests__/LocalNodeManager.api-port.test.ts +62 -0
  166. package/src/node/__tests__/LocalNodeManager.test.ts +144 -17
  167. package/src/templates/move/Move.toml +1 -1
  168. package/src/templates/move/sources/Counter.move +31 -4
  169. package/src/templates/scripts/deploy-counter.ts +10 -0
  170. package/src/types/config.ts +8 -1
  171. package/src/ui/__tests__/logger.test.ts +89 -0
  172. package/src/ui/formatters.ts +1 -1
  173. package/src/ui/logger.ts +62 -0
  174. package/src/ui/spinner.ts +47 -0
  175. package/src/utils/__tests__/childProcessAdapter.maxBuffer.test.ts +51 -0
  176. package/src/utils/childProcessAdapter.ts +32 -2
@@ -126,17 +126,18 @@ describe("LocalNodeManager — start / stop / lifecycle", () => {
126
126
  expect(info.testDir).toBe(tmpDir);
127
127
  });
128
128
 
129
- it("honors custom ports from constructor options", () => {
129
+ it("honors custom faucet/ready ports; apiPort is pinned to 8080 (see F9)", () => {
130
130
  const { adapter } = buildFakeAdapter();
131
131
  const mgr = new LocalNodeManager({
132
132
  adapter,
133
133
  testDir: tmpDir,
134
- apiPort: 9000,
135
134
  faucetPort: 9001,
136
135
  readyPort: 9002,
137
136
  });
138
137
  const info = mgr.getNodeInfo();
139
- expect(info.rpcUrl).toContain(":9000");
138
+ // Movement CLI does not accept a flag for the REST API port; see
139
+ // LocalNodeManager.api-port.test.ts for the F9 contract.
140
+ expect(info.rpcUrl).toBe("http://127.0.0.1:8080");
140
141
  expect(info.faucetUrl).toContain(":9001");
141
142
  expect(info.readyUrl).toContain(":9002");
142
143
  });
@@ -180,7 +181,7 @@ describe("LocalNodeManager — start / stop / lifecycle", () => {
180
181
  expect(proc.stderr!.listenerCount("data")).toBe(0);
181
182
  });
182
183
 
183
- it("start in non-silent mode wires stdout/stderr listeners that log events", async () => {
184
+ it("start in non-silent mode wires stdout/stderr listeners that respect §9 filtering", async () => {
184
185
  const { adapter, spawned } = buildFakeAdapter();
185
186
  stubFetchAlwaysOk();
186
187
  const mgr = new LocalNodeManager({ adapter, testDir: tmpDir, silent: false });
@@ -190,19 +191,9 @@ describe("LocalNodeManager — start / stop / lifecycle", () => {
190
191
  const proc = spawned[0]!;
191
192
  expect(proc.stdout!.listenerCount("data")).toBeGreaterThan(0);
192
193
  expect(proc.stderr!.listenerCount("data")).toBeGreaterThan(0);
193
-
194
- // Drive a line through stdout the manager's listener forwards to console.log.
195
- proc.stdout!.emit("data", Buffer.from("local node ready\n"));
196
- expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("local node ready"));
197
-
198
- // stderr non-WARN line goes through console.error.
199
- proc.stderr!.emit("data", Buffer.from("real error\n"));
200
- expect(errSpy).toHaveBeenCalledWith(expect.stringContaining("real error"));
201
-
202
- // stderr WARN line should be filtered (no error log).
203
- errSpy.mockClear();
204
- proc.stderr!.emit("data", Buffer.from("WARN something\n"));
205
- expect(errSpy).not.toHaveBeenCalledWith(expect.stringContaining("WARN"));
194
+ // Detailed filter behavior — what passes through vs what's gated
195
+ // behind isVerbose() lives in the "§9 console UX" describe block
196
+ // below. This test only locks the listener-wiring contract.
206
197
  });
207
198
 
208
199
  it("force-restart cleans the test directory before spawn", async () => {
@@ -450,3 +441,139 @@ describe("LocalNodeManager — fundAccount", () => {
450
441
  expect(fetchFn.mock.calls.length).toBeGreaterThanOrEqual(4);
451
442
  });
452
443
  });
444
+
445
+ describe("LocalNodeManager — subprocess output filtering (§9 console UX)", () => {
446
+ let tmpDir: string;
447
+ let logSpy: ReturnType<typeof vi.spyOn>;
448
+ let errSpy: ReturnType<typeof vi.spyOn>;
449
+ let warnSpy: ReturnType<typeof vi.spyOn>;
450
+
451
+ beforeEach(() => {
452
+ tmpDir = mkdtempSync(join(tmpdir(), "movehat-localnode-filter-"));
453
+ logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined);
454
+ errSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
455
+ warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined);
456
+ // Ensure quiet mode is the default for each test; the verbose tests
457
+ // set MOVEHAT_VERBOSE explicitly inside their own scope.
458
+ delete process.env.MOVEHAT_VERBOSE;
459
+ });
460
+
461
+ afterEach(() => {
462
+ vi.restoreAllMocks();
463
+ vi.unstubAllGlobals();
464
+ delete process.env.MOVEHAT_VERBOSE;
465
+ if (existsSync(tmpDir)) {
466
+ rmSync(tmpDir, { recursive: true, force: true });
467
+ }
468
+ });
469
+
470
+ it("hides routine stdout chatter from the movement node in quiet mode", async () => {
471
+ const { adapter, spawned } = buildFakeAdapter();
472
+ stubFetchAlwaysOk();
473
+ const mgr = new LocalNodeManager({ adapter, testDir: tmpDir });
474
+ await mgr.start();
475
+
476
+ const proc = spawned[0]!;
477
+ logSpy.mockClear();
478
+
479
+ // Push routine chatter — exactly the noise we used to spam to stdout.
480
+ proc.stdout!.emit("data", Buffer.from("Loading aptos framework module"));
481
+ proc.stdout!.emit("data", Buffer.from("Compiling Move bytecode"));
482
+ proc.stdout!.emit("data", Buffer.from("aptos_account: account created"));
483
+
484
+ // None of these should have reached stdout (no `[Node]` prefix, no
485
+ // muted gray `›` prefix). The only console.log calls that may arise
486
+ // are from the spinner mock falling back to plain text — but since
487
+ // ora auto-disables in non-TTY (vitest is non-TTY), even those are
488
+ // suppressed.
489
+ const stdoutCalls = logSpy.mock.calls.flat().join(" ");
490
+ expect(stdoutCalls).not.toContain("Loading aptos framework module");
491
+ expect(stdoutCalls).not.toContain("Compiling Move bytecode");
492
+ expect(stdoutCalls).not.toContain("aptos_account: account created");
493
+ });
494
+
495
+ it("always surfaces panic / fatal lines as warnings, even in quiet mode", async () => {
496
+ const { adapter, spawned } = buildFakeAdapter();
497
+ stubFetchAlwaysOk();
498
+ const mgr = new LocalNodeManager({ adapter, testDir: tmpDir });
499
+ await mgr.start();
500
+
501
+ const proc = spawned[0]!;
502
+ warnSpy.mockClear();
503
+
504
+ proc.stdout!.emit("data", Buffer.from("thread 'main' panicked at 'state corrupted'"));
505
+
506
+ const warnCalls = warnSpy.mock.calls.flat().join(" ");
507
+ expect(warnCalls).toContain("panicked");
508
+ });
509
+
510
+ it("surfaces 'address already in use' (port conflict) regardless of verbosity", async () => {
511
+ const { adapter, spawned } = buildFakeAdapter();
512
+ stubFetchAlwaysOk();
513
+ const mgr = new LocalNodeManager({ adapter, testDir: tmpDir });
514
+ await mgr.start();
515
+
516
+ const proc = spawned[0]!;
517
+ warnSpy.mockClear();
518
+
519
+ proc.stdout!.emit(
520
+ "data",
521
+ Buffer.from("error: address already in use: 127.0.0.1:8080"),
522
+ );
523
+
524
+ const warnCalls = warnSpy.mock.calls.flat().join(" ");
525
+ expect(warnCalls).toContain("address already in use");
526
+ });
527
+
528
+ it("surfaces stdout chatter with gray prefix in verbose mode", async () => {
529
+ process.env.MOVEHAT_VERBOSE = "1";
530
+ const { adapter, spawned } = buildFakeAdapter();
531
+ stubFetchAlwaysOk();
532
+ const mgr = new LocalNodeManager({ adapter, testDir: tmpDir });
533
+ await mgr.start();
534
+
535
+ const proc = spawned[0]!;
536
+ logSpy.mockClear();
537
+
538
+ proc.stdout!.emit("data", Buffer.from("Loading aptos framework module"));
539
+
540
+ const stdoutCalls = logSpy.mock.calls.flat().join(" ");
541
+ expect(stdoutCalls).toContain("Loading aptos framework module");
542
+ });
543
+
544
+ it("always surfaces critical stderr (panic/EADDRINUSE) via logger.error", async () => {
545
+ const { adapter, spawned } = buildFakeAdapter();
546
+ stubFetchAlwaysOk();
547
+ const mgr = new LocalNodeManager({ adapter, testDir: tmpDir });
548
+ await mgr.start();
549
+
550
+ const proc = spawned[0]!;
551
+ errSpy.mockClear();
552
+
553
+ proc.stderr!.emit("data", Buffer.from("thread panicked: failed to bind socket"));
554
+
555
+ const stderrCalls = errSpy.mock.calls.flat().join(" ");
556
+ expect(stderrCalls).toContain("panicked");
557
+ });
558
+
559
+ it("hides routine progress stderr in quiet mode (Movement CLI emits progress to stderr too)", async () => {
560
+ const { adapter, spawned } = buildFakeAdapter();
561
+ stubFetchAlwaysOk();
562
+ const mgr = new LocalNodeManager({ adapter, testDir: tmpDir });
563
+ await mgr.start();
564
+
565
+ const proc = spawned[0]!;
566
+ errSpy.mockClear();
567
+
568
+ // The movement subprocess emits progress messages to stderr that
569
+ // are NOT errors: "Applying post startup steps...", "Compiling,
570
+ // may take a little while...". Hiding stream channel from the
571
+ // user keeps the console clean.
572
+ proc.stderr!.emit("data", Buffer.from("Applying post startup steps..."));
573
+ proc.stderr!.emit("data", Buffer.from("[WARN] deprecated config field"));
574
+
575
+ const stderrCalls = errSpy.mock.calls.flat().join(" ");
576
+ expect(stderrCalls).not.toContain("Applying post startup steps");
577
+ expect(stderrCalls).not.toContain("deprecated config field");
578
+ });
579
+ });
@@ -1,5 +1,5 @@
1
1
  [package]
2
- name = "{{projectName}}"
2
+ name = "{{movePackageName}}"
3
3
  version = "1.0.0"
4
4
  authors = []
5
5
 
@@ -33,7 +33,16 @@ module counter::counter {
33
33
 
34
34
  public entry fun increment(account: &signer) acquires Counter {
35
35
  let account_addr = signer::address_of(account);
36
- assert!(exists<Counter>(account_addr), E_NOT_INITIALIZED);
36
+
37
+ // Auto-init: create Counter if it doesn't exist yet. Defense in
38
+ // depth so the module stays usable even if a caller skips the
39
+ // dedicated `init` entry function.
40
+ if (!exists<Counter>(account_addr)) {
41
+ move_to(account, Counter {
42
+ value: 0,
43
+ increment_events: account::new_event_handle<IncrementEvent>(account),
44
+ });
45
+ };
37
46
 
38
47
  let counter = borrow_global_mut<Counter>(account_addr);
39
48
  let old_value = counter.value;
@@ -56,14 +65,32 @@ module counter::counter {
56
65
  public fun test_increment(account: &signer) acquires Counter {
57
66
  let addr = signer::address_of(account);
58
67
  aptos_framework::account::create_account_for_test(addr);
59
-
68
+
60
69
  init(account);
61
70
  assert!(get(addr) == 0, 0);
62
-
71
+
63
72
  increment(account);
64
73
  assert!(get(addr) == 1, 1);
65
-
74
+
66
75
  increment(account);
67
76
  assert!(get(addr) == 2, 2);
68
77
  }
78
+
79
+ // Regression guard: increment must auto-create the Counter resource
80
+ // when called against a never-initialized account. Locks the
81
+ // defense-in-depth behavior so a future refactor can't accidentally
82
+ // remove it.
83
+ #[test(account = @0x2)]
84
+ public fun test_increment_auto_inits(account: &signer) acquires Counter {
85
+ let addr = signer::address_of(account);
86
+ aptos_framework::account::create_account_for_test(addr);
87
+
88
+ // Skip init entirely — increment must create the resource.
89
+ increment(account);
90
+ assert!(get(addr) == 1, 0);
91
+
92
+ // Idempotent: a second increment uses the now-existing resource.
93
+ increment(account);
94
+ assert!(get(addr) == 2, 1);
95
+ }
69
96
  }
@@ -31,6 +31,16 @@ async function main() {
31
31
  // Interact with the freshly deployed module via the runtime helper.
32
32
  const counter = harness.runtime.getContract(deployment.address, "counter");
33
33
 
34
+ // Counter is a Move resource — it must be created explicitly per
35
+ // account before any method that reads or mutates it. The dedicated
36
+ // `init` entry function does this once per signer. (The module also
37
+ // auto-inits inside `increment` as defense in depth, so this call is
38
+ // technically optional today, but kept for pedagogy: real-world Move
39
+ // modules usually require an explicit init step.)
40
+ console.log("\n🔧 Initializing counter resource for this account...");
41
+ const initTx = await counter.call(harness.runtime.account, "init", []);
42
+ console.log(` Init tx: ${initTx.hash}`);
43
+
34
44
  console.log("\n📝 Incrementing counter...");
35
45
  const txResult = await counter.call(harness.runtime.account, "increment", []);
36
46
  console.log(`✅ Transaction hash: ${txResult.hash}`);
@@ -55,7 +55,14 @@ export interface LocalTestOptions {
55
55
  nodeSilent?: boolean; // Suppress node output (default: false)
56
56
 
57
57
  // Fork options (when mode='fork')
58
- forkNetwork?: 'testnet' | string; // Network to fork from (default: 'testnet')
58
+ forkNetwork?: 'testnet' | 'mainnet' | string; // Network to fork from (default: 'testnet')
59
+ /**
60
+ * RPC URL override used when forking a non-built-in network.
61
+ * Required when `forkNetwork` is not one of the built-in names
62
+ * (`'testnet'`, `'mainnet'`). Ignored when a fork already exists
63
+ * on disk (the saved metadata's nodeUrl is reused).
64
+ */
65
+ forkRpcUrl?: string;
59
66
  forkName?: string; // Name for the fork (default: 'test-local')
60
67
  forkPort?: number; // Fork server port (default: 8080)
61
68
  forkResetState?: boolean; // Clear fork state before tests (default: true)
@@ -0,0 +1,89 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ import { configureLogger, isVerbose, divider, phase } from "../logger.js";
4
+
5
+ describe("logger — verbosity", () => {
6
+ let originalEnv: string | undefined;
7
+
8
+ beforeEach(() => {
9
+ originalEnv = process.env.MOVEHAT_VERBOSE;
10
+ // Start each test from a clean slate so prior runs cannot leak.
11
+ delete process.env.MOVEHAT_VERBOSE;
12
+ configureLogger({ verbosity: "normal" });
13
+ });
14
+
15
+ afterEach(() => {
16
+ if (originalEnv === undefined) {
17
+ delete process.env.MOVEHAT_VERBOSE;
18
+ } else {
19
+ process.env.MOVEHAT_VERBOSE = originalEnv;
20
+ }
21
+ configureLogger({ verbosity: "normal" });
22
+ });
23
+
24
+ it("defaults to non-verbose when MOVEHAT_VERBOSE is unset and config is normal", () => {
25
+ expect(isVerbose()).toBe(false);
26
+ });
27
+
28
+ it("returns true when MOVEHAT_VERBOSE=1 (env-driven path)", () => {
29
+ process.env.MOVEHAT_VERBOSE = "1";
30
+ expect(isVerbose()).toBe(true);
31
+ });
32
+
33
+ it("returns true when configureLogger({ verbosity: 'verbose' }) is set in-process", () => {
34
+ configureLogger({ verbosity: "verbose" });
35
+ expect(isVerbose()).toBe(true);
36
+ });
37
+
38
+ it("env var wins even when in-process config is normal (allows shell-script callers)", () => {
39
+ configureLogger({ verbosity: "normal" });
40
+ process.env.MOVEHAT_VERBOSE = "1";
41
+ expect(isVerbose()).toBe(true);
42
+ });
43
+
44
+ it("non-'1' MOVEHAT_VERBOSE values do not enable verbose mode", () => {
45
+ process.env.MOVEHAT_VERBOSE = "true";
46
+ expect(isVerbose()).toBe(false);
47
+ process.env.MOVEHAT_VERBOSE = "0";
48
+ expect(isVerbose()).toBe(false);
49
+ });
50
+ });
51
+
52
+ describe("logger.phase / logger.divider", () => {
53
+ let logSpy: ReturnType<typeof vi.spyOn>;
54
+
55
+ beforeEach(() => {
56
+ logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined);
57
+ configureLogger({ silent: false });
58
+ });
59
+
60
+ afterEach(() => {
61
+ vi.restoreAllMocks();
62
+ });
63
+
64
+ it("phase prints three lines: top rule, indented title, bottom rule", () => {
65
+ phase("Local Movement node");
66
+ expect(logSpy).toHaveBeenCalledTimes(3);
67
+
68
+ const calls = logSpy.mock.calls.map((c: unknown[]) => String(c[0]));
69
+ // The title line carries the supplied text after the two-space indent.
70
+ expect(calls[1]).toContain("Local Movement node");
71
+ // Top and bottom rules should be identical (same width, same color).
72
+ expect(calls[0]).toBe(calls[2]);
73
+ });
74
+
75
+ it("divider prints a single muted rule line", () => {
76
+ divider();
77
+ expect(logSpy).toHaveBeenCalledTimes(1);
78
+ const line = String(logSpy.mock.calls[0]?.[0] ?? "");
79
+ // The rule character is `━` (BOX DRAWINGS HEAVY HORIZONTAL).
80
+ expect(line).toMatch(/━+/);
81
+ });
82
+
83
+ it("phase and divider are silenced when configureLogger({ silent: true })", () => {
84
+ configureLogger({ silent: true });
85
+ phase("hidden phase");
86
+ divider();
87
+ expect(logSpy).not.toHaveBeenCalled();
88
+ });
89
+ });
@@ -176,7 +176,7 @@ export const indent = (text: string, spaces: number): string => {
176
176
  * console.log(divider(40, '='));
177
177
  * // ========================================
178
178
  */
179
- export const divider = (
179
+ const divider = (
180
180
  width: number = 60,
181
181
  char: string = symbols.line
182
182
  ): string => {
package/src/ui/logger.ts CHANGED
@@ -6,6 +6,14 @@ import { coloredSymbol, symbols } from './symbols.js';
6
6
  */
7
7
  export type LogLevel = 'info' | 'success' | 'error' | 'warning' | 'debug';
8
8
 
9
+ /**
10
+ * Verbosity level for subprocess output and gray-prefixed chatter.
11
+ * - `quiet` — reserved; currently behaves like `normal`
12
+ * - `normal` — default; system logs only, subprocess chatter hidden
13
+ * - `verbose` — surface subprocess stdout with a muted gray `›` prefix
14
+ */
15
+ export type Verbosity = 'quiet' | 'normal' | 'verbose';
16
+
9
17
  /**
10
18
  * Logger configuration options
11
19
  */
@@ -16,6 +24,8 @@ export interface LoggerConfig {
16
24
  level?: LogLevel;
17
25
  /** Include timestamps in log messages */
18
26
  timestamp?: boolean;
27
+ /** Verbosity level for subprocess output */
28
+ verbosity?: Verbosity;
19
29
  }
20
30
 
21
31
  /**
@@ -25,8 +35,18 @@ let config: LoggerConfig = {
25
35
  silent: false,
26
36
  level: 'info',
27
37
  timestamp: false,
38
+ verbosity: process.env.MOVEHAT_VERBOSE === '1' ? 'verbose' : 'normal',
28
39
  };
29
40
 
41
+ /**
42
+ * Whether subprocess chatter should reach the user's terminal.
43
+ * Honors both the in-process config (set by the `-v` CLI flag's
44
+ * preAction hook) and the `MOVEHAT_VERBOSE=1` env var (which lets
45
+ * callers opt in before the CLI parses args, e.g. in shell scripts).
46
+ */
47
+ export const isVerbose = (): boolean =>
48
+ config.verbosity === 'verbose' || process.env.MOVEHAT_VERBOSE === '1';
49
+
30
50
  /**
31
51
  * Configure logger globally
32
52
  *
@@ -186,6 +206,45 @@ export const section = (title: string): void => {
186
206
  console.log(`\n${colors.brandBright(title)}`);
187
207
  };
188
208
 
209
+ /**
210
+ * Width of the `━` rule used by `phase` and `divider`. Matched to a
211
+ * comfortable terminal width that fits in a side-by-side dev layout.
212
+ */
213
+ const PHASE_RULE_WIDTH = 52;
214
+
215
+ /**
216
+ * Single muted horizontal rule. Use to close out a phase or to
217
+ * visually separate output sections.
218
+ */
219
+ export const divider = (): void => {
220
+ if (config.silent) return;
221
+ console.log(colors.muted('━'.repeat(PHASE_RULE_WIDTH)));
222
+ };
223
+
224
+ /**
225
+ * Phase header — renders a muted top rule, a bold brand-colored title
226
+ * indented two spaces, and a muted bottom rule. Use at top-level phase
227
+ * boundaries (local node start, deploy flow, test orchestrator
228
+ * sections) so the user can visually anchor where one phase ends and
229
+ * the next begins.
230
+ *
231
+ * @param title - Phase title (e.g. "Local Movement node")
232
+ *
233
+ * @example
234
+ * logger.phase('Local Movement node');
235
+ * // Renders:
236
+ * // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
237
+ * // Local Movement node
238
+ * // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
239
+ */
240
+ export const phase = (title: string): void => {
241
+ if (config.silent) return;
242
+ const rule = colors.muted('━'.repeat(PHASE_RULE_WIDTH));
243
+ console.log(rule);
244
+ console.log(` ${colors.brandBright(title)}`);
245
+ console.log(rule);
246
+ };
247
+
189
248
  /**
190
249
  * Key-value pair
191
250
  * Use for displaying structured data
@@ -235,6 +294,7 @@ export const item = (text: string, indent: number = 0): void => {
235
294
  */
236
295
  export const logger = {
237
296
  configure: configureLogger,
297
+ isVerbose,
238
298
  info,
239
299
  success,
240
300
  error,
@@ -243,6 +303,8 @@ export const logger = {
243
303
  plain,
244
304
  newline,
245
305
  section,
306
+ phase,
307
+ divider,
246
308
  kv,
247
309
  item,
248
310
  };
package/src/ui/spinner.ts CHANGED
@@ -103,6 +103,53 @@ export const withSpinner = async <T>(
103
103
  }
104
104
  };
105
105
 
106
+ /**
107
+ * Execute async task with a spinner that updates its label with
108
+ * elapsed seconds while the task runs. Use for long-running phases
109
+ * (local node startup, publish + tx wait) where the user wants
110
+ * visible progress feedback in lieu of subprocess chatter.
111
+ *
112
+ * Pairs with the `§9` console-UX convention: any phase that
113
+ * empirically takes ≥3s in normal use should wrap its body in
114
+ * `withTimedSpinner` so the terminal never goes silent while work
115
+ * happens.
116
+ *
117
+ * @param label - Stable label shown next to the spinner (e.g. "Starting node")
118
+ * @param task - Async function to execute
119
+ * @param indent - Number of spaces to indent (default: 0)
120
+ * @returns Promise resolving to task result
121
+ *
122
+ * @example
123
+ * await withTimedSpinner('Starting local node', async () => {
124
+ * await this.waitForReady(60_000);
125
+ * });
126
+ * // Renders: ⠋ Starting local node — 0.0s ... ⠼ Starting local node — 14.2s
127
+ * // On success: ✔ Starting local node (14.2s)
128
+ * // On error: ✖ <error.message>
129
+ */
130
+ export const withTimedSpinner = async <T>(
131
+ label: string,
132
+ task: () => Promise<T>,
133
+ indent: number = 0
134
+ ): Promise<T> => {
135
+ const start = Date.now();
136
+ const spin = spinner({ text: `${label} — 0.0s`, indent });
137
+ const timer = setInterval(() => {
138
+ spin.text = `${label} — ${((Date.now() - start) / 1000).toFixed(1)}s`;
139
+ }, 500);
140
+ try {
141
+ const result = await task();
142
+ spin.succeed(`${label} (${((Date.now() - start) / 1000).toFixed(1)}s)`);
143
+ return result;
144
+ } catch (error) {
145
+ const errMsg = error instanceof Error ? error.message : String(error);
146
+ spin.fail(errMsg);
147
+ throw error;
148
+ } finally {
149
+ clearInterval(timer);
150
+ }
151
+ };
152
+
106
153
  /**
107
154
  * Spinner chain for sequential operations
108
155
  * Manages multiple spinners in sequence
@@ -0,0 +1,51 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { defaultChildProcessAdapter } from "../childProcessAdapter.js";
3
+
4
+ /**
5
+ * F4 — `run()` must reject when child output exceeds `maxBuffer`.
6
+ *
7
+ * Without this cap, the stdout/stderr Buffer arrays in
8
+ * DefaultChildProcessAdapter grow without limit. A buggy or hostile
9
+ * subprocess can OOM the parent process. F4 adds an opt-in byte cap
10
+ * with kill-on-overflow semantics.
11
+ */
12
+
13
+ const NODE = process.execPath;
14
+
15
+ describe("F4 — ChildProcessAdapter.run maxBuffer", () => {
16
+ it("rejects with a maxBuffer error when stdout exceeds the cap", async () => {
17
+ // 8KiB of output, cap at 1KiB → must abort.
18
+ const script = `process.stdout.write('x'.repeat(8 * 1024)); setTimeout(() => {}, 30000);`;
19
+ await expect(
20
+ defaultChildProcessAdapter.run({
21
+ command: NODE,
22
+ args: ["-e", script],
23
+ maxBuffer: 1024,
24
+ timeoutMs: 10_000,
25
+ })
26
+ ).rejects.toThrow(/maxBuffer|exceeded/i);
27
+ });
28
+
29
+ it("rejects with a maxBuffer error when stderr exceeds the cap", async () => {
30
+ const script = `process.stderr.write('y'.repeat(8 * 1024)); setTimeout(() => {}, 30000);`;
31
+ await expect(
32
+ defaultChildProcessAdapter.run({
33
+ command: NODE,
34
+ args: ["-e", script],
35
+ maxBuffer: 1024,
36
+ timeoutMs: 10_000,
37
+ })
38
+ ).rejects.toThrow(/maxBuffer|exceeded/i);
39
+ });
40
+
41
+ it("does NOT throw when output stays under the cap", async () => {
42
+ const script = `process.stdout.write('ok'); process.exit(0);`;
43
+ const result = await defaultChildProcessAdapter.run({
44
+ command: NODE,
45
+ args: ["-e", script],
46
+ maxBuffer: 1024,
47
+ });
48
+ expect(result.exitCode).toBe(0);
49
+ expect(result.stdout).toBe("ok");
50
+ });
51
+ });
@@ -38,8 +38,17 @@ export interface RunInput {
38
38
  * Default: `false`.
39
39
  */
40
40
  inheritStdio?: boolean;
41
+ /**
42
+ * Maximum combined bytes (stdout + stderr) the captured Buffers may
43
+ * grow to before the child is killed and the promise rejects. Defaults
44
+ * to 64 MiB. Set to `Infinity` to disable. Ignored when
45
+ * `inheritStdio` is `true` (no buffering happens).
46
+ */
47
+ maxBuffer?: number;
41
48
  }
42
49
 
50
+ const DEFAULT_MAX_BUFFER = 64 * 1024 * 1024;
51
+
43
52
  export interface RunResult {
44
53
  /**
45
54
  * Numeric exit code from the child. `-1` when the child was terminated by
@@ -114,10 +123,31 @@ class DefaultChildProcessAdapter implements ChildProcessAdapter {
114
123
 
115
124
  const stdoutChunks: Buffer[] = [];
116
125
  const stderrChunks: Buffer[] = [];
126
+ let totalBytes = 0;
127
+ let overflowed = false;
128
+ const maxBuffer = input.maxBuffer ?? DEFAULT_MAX_BUFFER;
129
+
130
+ const onChunk = (chunks: Buffer[]) => (chunk: Buffer) => {
131
+ if (overflowed) return;
132
+ totalBytes += chunk.length;
133
+ if (totalBytes > maxBuffer) {
134
+ overflowed = true;
135
+ clearTimer();
136
+ input.signal?.removeEventListener('abort', onAbort);
137
+ child.kill('SIGTERM');
138
+ reject(
139
+ new Error(
140
+ `Command output exceeded maxBuffer (${maxBuffer} bytes): ${input.command}`
141
+ )
142
+ );
143
+ return;
144
+ }
145
+ chunks.push(chunk);
146
+ };
117
147
 
118
148
  // Streams are null when stdio is 'inherit'; the `?.` covers that.
119
- child.stdout?.on('data', (chunk: Buffer) => stdoutChunks.push(chunk));
120
- child.stderr?.on('data', (chunk: Buffer) => stderrChunks.push(chunk));
149
+ child.stdout?.on('data', onChunk(stdoutChunks));
150
+ child.stderr?.on('data', onChunk(stderrChunks));
121
151
 
122
152
  let timeoutHandle: NodeJS.Timeout | undefined;
123
153
  const clearTimer = () => {