@indigoai-us/hq-cloud 5.1.0 → 5.1.8

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 (100) hide show
  1. package/dist/bin/sync-runner.d.ts +111 -0
  2. package/dist/bin/sync-runner.d.ts.map +1 -0
  3. package/dist/bin/sync-runner.js +285 -0
  4. package/dist/bin/sync-runner.js.map +1 -0
  5. package/dist/bin/sync-runner.test.d.ts +10 -0
  6. package/dist/bin/sync-runner.test.d.ts.map +1 -0
  7. package/dist/bin/sync-runner.test.js +492 -0
  8. package/dist/bin/sync-runner.test.js.map +1 -0
  9. package/dist/cli/index.d.ts +1 -1
  10. package/dist/cli/index.d.ts.map +1 -1
  11. package/dist/cli/share.js +2 -2
  12. package/dist/cli/share.js.map +1 -1
  13. package/dist/cli/share.test.js +9 -1
  14. package/dist/cli/share.test.js.map +1 -1
  15. package/dist/cli/sync.d.ts +28 -0
  16. package/dist/cli/sync.d.ts.map +1 -1
  17. package/dist/cli/sync.js +33 -10
  18. package/dist/cli/sync.js.map +1 -1
  19. package/dist/cli/sync.test.js +15 -4
  20. package/dist/cli/sync.test.js.map +1 -1
  21. package/dist/cognito-auth.d.ts.map +1 -1
  22. package/dist/cognito-auth.js +19 -1
  23. package/dist/cognito-auth.js.map +1 -1
  24. package/dist/cognito-auth.test.d.ts +9 -0
  25. package/dist/cognito-auth.test.d.ts.map +1 -0
  26. package/dist/cognito-auth.test.js +113 -0
  27. package/dist/cognito-auth.test.js.map +1 -0
  28. package/dist/context.d.ts.map +1 -1
  29. package/dist/context.js +1 -0
  30. package/dist/context.js.map +1 -1
  31. package/dist/daemon-worker.d.ts +6 -1
  32. package/dist/daemon-worker.d.ts.map +1 -1
  33. package/dist/daemon-worker.js +12 -16
  34. package/dist/daemon-worker.js.map +1 -1
  35. package/dist/daemon.d.ts +2 -0
  36. package/dist/daemon.d.ts.map +1 -1
  37. package/dist/daemon.js +2 -0
  38. package/dist/daemon.js.map +1 -1
  39. package/dist/ignore.d.ts +13 -2
  40. package/dist/ignore.d.ts.map +1 -1
  41. package/dist/ignore.js +69 -12
  42. package/dist/ignore.js.map +1 -1
  43. package/dist/index.d.ts +24 -28
  44. package/dist/index.d.ts.map +1 -1
  45. package/dist/index.js +19 -134
  46. package/dist/index.js.map +1 -1
  47. package/dist/journal.d.ts +20 -4
  48. package/dist/journal.d.ts.map +1 -1
  49. package/dist/journal.js +45 -8
  50. package/dist/journal.js.map +1 -1
  51. package/dist/journal.test.d.ts +9 -0
  52. package/dist/journal.test.d.ts.map +1 -0
  53. package/dist/journal.test.js +114 -0
  54. package/dist/journal.test.js.map +1 -0
  55. package/dist/s3.d.ts +18 -6
  56. package/dist/s3.d.ts.map +1 -1
  57. package/dist/s3.js +57 -56
  58. package/dist/s3.js.map +1 -1
  59. package/dist/types.d.ts +34 -0
  60. package/dist/types.d.ts.map +1 -1
  61. package/dist/vault-client.d.ts +16 -0
  62. package/dist/vault-client.d.ts.map +1 -1
  63. package/dist/vault-client.js +19 -0
  64. package/dist/vault-client.js.map +1 -1
  65. package/dist/vault-client.test.js +25 -0
  66. package/dist/vault-client.test.js.map +1 -1
  67. package/dist/watcher.d.ts +7 -1
  68. package/dist/watcher.d.ts.map +1 -1
  69. package/dist/watcher.js +11 -5
  70. package/dist/watcher.js.map +1 -1
  71. package/package.json +15 -3
  72. package/src/bin/sync-runner.test.ts +617 -0
  73. package/src/bin/sync-runner.ts +390 -0
  74. package/src/cli/accept.ts +97 -0
  75. package/src/cli/conflict.ts +119 -0
  76. package/src/cli/index.ts +25 -0
  77. package/src/cli/invite.test.ts +247 -0
  78. package/src/cli/invite.ts +180 -0
  79. package/src/cli/promote.ts +123 -0
  80. package/src/cli/share.test.ts +155 -0
  81. package/src/cli/share.ts +212 -0
  82. package/src/cli/sync.test.ts +225 -0
  83. package/src/cli/sync.ts +225 -0
  84. package/src/cognito-auth.test.ts +156 -0
  85. package/src/cognito-auth.ts +18 -1
  86. package/src/context.test.ts +202 -0
  87. package/src/context.ts +178 -0
  88. package/src/daemon-worker.ts +13 -19
  89. package/src/daemon.ts +2 -0
  90. package/src/ignore.ts +76 -12
  91. package/src/index.ts +93 -165
  92. package/src/journal.test.ts +146 -0
  93. package/src/journal.ts +53 -11
  94. package/src/s3.ts +76 -66
  95. package/src/types.ts +37 -0
  96. package/src/vault-client.test.ts +390 -0
  97. package/src/vault-client.ts +400 -0
  98. package/src/watcher.ts +12 -5
  99. package/test/invite-flow.integration.test.ts +244 -0
  100. package/test/share-sync.integration.test.ts +210 -0
@@ -0,0 +1,617 @@
1
+ /**
2
+ * Unit tests for hq-sync-runner (ADR-0001).
3
+ *
4
+ * The runner is designed around `RunnerDeps` — every side effect is
5
+ * injectable, so tests assert on captured ndjson output rather than mocking
6
+ * modules. That keeps each test honest about what the runner does vs what
7
+ * its collaborators do.
8
+ */
9
+
10
+ import { describe, it, expect, vi, beforeEach } from "vitest";
11
+ import { runRunner } from "./sync-runner.js";
12
+ import type {
13
+ RunnerEvent,
14
+ RunnerDeps,
15
+ VaultClientSurface,
16
+ } from "./sync-runner.js";
17
+ import type { SyncResult, SyncOptions } from "../cli/sync.js";
18
+ import type { Membership, EntityInfo } from "../vault-client.js";
19
+ import { VaultAuthError } from "../vault-client.js";
20
+
21
+ // ---------------------------------------------------------------------------
22
+ // Capturing writer — collects writes so we can assert on the ndjson stream
23
+ // ---------------------------------------------------------------------------
24
+
25
+ interface CapturingWriter {
26
+ write: (chunk: string) => boolean;
27
+ lines: () => string[];
28
+ events: () => RunnerEvent[];
29
+ raw: () => string;
30
+ }
31
+
32
+ function makeWriter(): CapturingWriter {
33
+ let buf = "";
34
+ return {
35
+ write: (chunk: string) => {
36
+ buf += chunk;
37
+ return true;
38
+ },
39
+ lines: () => buf.split("\n").filter((l) => l.length > 0),
40
+ events: () =>
41
+ buf
42
+ .split("\n")
43
+ .filter((l) => l.length > 0)
44
+ .map((l) => JSON.parse(l) as RunnerEvent),
45
+ raw: () => buf,
46
+ };
47
+ }
48
+
49
+ // ---------------------------------------------------------------------------
50
+ // Default stub factory — tests override individual fields
51
+ // ---------------------------------------------------------------------------
52
+
53
+ function defaultSyncResult(overrides: Partial<SyncResult> = {}): SyncResult {
54
+ return {
55
+ filesDownloaded: 0,
56
+ bytesDownloaded: 0,
57
+ filesSkipped: 0,
58
+ conflicts: 0,
59
+ aborted: false,
60
+ ...overrides,
61
+ };
62
+ }
63
+
64
+ /**
65
+ * Produce a minimal VaultClientSurface stub. Tests pass in the memberships
66
+ * they want `listMyMemberships` to return, plus the sequence of `entity.get`
67
+ * resolutions. Defaults cover the "no memberships" path.
68
+ */
69
+ function makeVaultStub(
70
+ opts: {
71
+ memberships?: Array<Pick<Membership, "companyUid">>;
72
+ entityGet?: (uid: string) => Promise<EntityInfo>;
73
+ } = {},
74
+ ): VaultClientSurface {
75
+ const memberships = opts.memberships ?? [];
76
+ return {
77
+ listMyMemberships: () => Promise.resolve(memberships as Membership[]),
78
+ entity: {
79
+ get:
80
+ opts.entityGet ??
81
+ ((uid: string) =>
82
+ Promise.resolve({
83
+ uid,
84
+ type: "company",
85
+ slug: uid,
86
+ bucketName: `bucket-${uid}`,
87
+ status: "active",
88
+ } as unknown as EntityInfo)),
89
+ },
90
+ };
91
+ }
92
+
93
+ interface TestDeps extends RunnerDeps {
94
+ stdout: CapturingWriter;
95
+ stderr: CapturingWriter;
96
+ }
97
+
98
+ function makeDeps(overrides: Partial<RunnerDeps> = {}): TestDeps {
99
+ const stdout = makeWriter();
100
+ const stderr = makeWriter();
101
+ // Spread overrides first so our CapturingWriter stdout/stderr always
102
+ // survive in the returned shape. Tests cannot override those — capturing
103
+ // is the whole point of the helper. vi.fn() wraps defaults so tests can
104
+ // still call .toHaveBeenCalled() / .toHaveBeenCalledTimes() on the returned
105
+ // deps without each override re-wrapping.
106
+ return {
107
+ getAccessToken: vi.fn().mockResolvedValue("test-access-token"),
108
+ createVaultClient: vi.fn().mockImplementation(() => makeVaultStub()),
109
+ sync: vi.fn().mockResolvedValue(defaultSyncResult()),
110
+ ...overrides,
111
+ stdout,
112
+ stderr,
113
+ };
114
+ }
115
+
116
+ // ---------------------------------------------------------------------------
117
+ // argv parsing
118
+ // ---------------------------------------------------------------------------
119
+
120
+ describe("argv parsing", () => {
121
+ it("rejects missing mode with exit 1", async () => {
122
+ const deps = makeDeps();
123
+ const code = await runRunner([], deps);
124
+ expect(code).toBe(1);
125
+ expect(deps.stderr.raw()).toContain("--companies or --company");
126
+ expect(deps.stdout.events()).toEqual([]);
127
+ });
128
+
129
+ it("rejects --companies + --company together", async () => {
130
+ const deps = makeDeps();
131
+ const code = await runRunner(["--companies", "--company", "acme"], deps);
132
+ expect(code).toBe(1);
133
+ expect(deps.stderr.raw()).toContain("not both");
134
+ expect(deps.stdout.events()).toEqual([]);
135
+ });
136
+
137
+ it("rejects unknown flags", async () => {
138
+ const deps = makeDeps();
139
+ const code = await runRunner(["--companies", "--wat"], deps);
140
+ expect(code).toBe(1);
141
+ expect(deps.stderr.raw()).toContain("Unknown argument: --wat");
142
+ });
143
+
144
+ it("rejects invalid --on-conflict value", async () => {
145
+ const deps = makeDeps();
146
+ const code = await runRunner(
147
+ ["--companies", "--on-conflict", "nuke"],
148
+ deps,
149
+ );
150
+ expect(code).toBe(1);
151
+ expect(deps.stderr.raw()).toContain("abort|overwrite|keep");
152
+ });
153
+
154
+ it("accepts --json as a silent no-op (ndjson is the only mode)", async () => {
155
+ const deps = makeDeps({
156
+ createVaultClient: () => makeVaultStub({ memberships: [] }),
157
+ });
158
+ const code = await runRunner(["--companies", "--json"], deps);
159
+ expect(code).toBe(0);
160
+ // Empty memberships → setup-needed, not a parse error
161
+ expect(deps.stdout.events()).toEqual([{ type: "setup-needed" }]);
162
+ });
163
+ });
164
+
165
+ // ---------------------------------------------------------------------------
166
+ // auth
167
+ // ---------------------------------------------------------------------------
168
+
169
+ describe("auth", () => {
170
+ it("emits auth-error and returns 0 when token fetch fails", async () => {
171
+ const deps = makeDeps({
172
+ getAccessToken: vi.fn().mockRejectedValue(new Error("no cached tokens")),
173
+ });
174
+ const code = await runRunner(["--companies"], deps);
175
+ expect(code).toBe(0);
176
+ expect(deps.stdout.events()).toEqual([
177
+ { type: "auth-error", message: "no cached tokens" },
178
+ ]);
179
+ });
180
+
181
+ it("emits auth-error when VaultAuthError thrown during discovery", async () => {
182
+ const deps = makeDeps({
183
+ createVaultClient: () => ({
184
+ listMyMemberships: () =>
185
+ Promise.reject(new VaultAuthError("token expired")),
186
+ entity: {
187
+ get: (uid: string) =>
188
+ Promise.resolve({ uid, slug: uid } as unknown as EntityInfo),
189
+ },
190
+ }),
191
+ });
192
+ const code = await runRunner(["--companies"], deps);
193
+ expect(code).toBe(0);
194
+ expect(deps.stdout.events()).toEqual([
195
+ { type: "auth-error", message: "token expired" },
196
+ ]);
197
+ });
198
+
199
+ it("emits error event and returns 1 on non-auth discovery failure", async () => {
200
+ const deps = makeDeps({
201
+ createVaultClient: () => ({
202
+ listMyMemberships: () => Promise.reject(new Error("network down")),
203
+ entity: {
204
+ get: (uid: string) =>
205
+ Promise.resolve({ uid, slug: uid } as unknown as EntityInfo),
206
+ },
207
+ }),
208
+ });
209
+ const code = await runRunner(["--companies"], deps);
210
+ expect(code).toBe(1);
211
+ const events = deps.stdout.events();
212
+ expect(events).toHaveLength(1);
213
+ expect(events[0]).toMatchObject({
214
+ type: "error",
215
+ message: "network down",
216
+ path: "(discovery)",
217
+ });
218
+ });
219
+ });
220
+
221
+ // ---------------------------------------------------------------------------
222
+ // target resolution
223
+ // ---------------------------------------------------------------------------
224
+
225
+ describe("target resolution", () => {
226
+ it("emits setup-needed when --companies returns no memberships", async () => {
227
+ const deps = makeDeps();
228
+ const code = await runRunner(["--companies"], deps);
229
+ expect(code).toBe(0);
230
+ expect(deps.stdout.events()).toEqual([{ type: "setup-needed" }]);
231
+ // sync should NOT have been called — no targets
232
+ expect(deps.sync).not.toHaveBeenCalled();
233
+ });
234
+
235
+ it("single-company mode skips listMyMemberships and syncs the named UID", async () => {
236
+ const listSpy = vi.fn();
237
+ const deps = makeDeps({
238
+ createVaultClient: () => ({
239
+ listMyMemberships: listSpy as unknown as () => Promise<Membership[]>,
240
+ entity: {
241
+ get: (uid: string) =>
242
+ Promise.resolve({ uid, slug: "acme" } as unknown as EntityInfo),
243
+ },
244
+ }),
245
+ });
246
+ const code = await runRunner(["--company", "cmp_abc"], deps);
247
+ expect(code).toBe(0);
248
+ expect(listSpy).not.toHaveBeenCalled();
249
+ expect(deps.sync).toHaveBeenCalledTimes(1);
250
+ const call = (deps.sync as ReturnType<typeof vi.fn>).mock.calls[0][0] as SyncOptions;
251
+ expect(call.company).toBe("cmp_abc");
252
+ });
253
+ });
254
+
255
+ // ---------------------------------------------------------------------------
256
+ // fanout-plan
257
+ // ---------------------------------------------------------------------------
258
+
259
+ describe("fanout-plan", () => {
260
+ it("resolves slugs from entity.get before fanning out", async () => {
261
+ const slugByUid: Record<string, string> = {
262
+ cmp_a: "acme",
263
+ cmp_b: "beta",
264
+ };
265
+ const deps = makeDeps({
266
+ createVaultClient: () =>
267
+ makeVaultStub({
268
+ memberships: [{ companyUid: "cmp_a" }, { companyUid: "cmp_b" }],
269
+ entityGet: (uid: string) =>
270
+ Promise.resolve({
271
+ uid,
272
+ slug: slugByUid[uid] ?? uid,
273
+ } as unknown as EntityInfo),
274
+ }),
275
+ });
276
+
277
+ const code = await runRunner(["--companies"], deps);
278
+ expect(code).toBe(0);
279
+ const plan = deps.stdout
280
+ .events()
281
+ .find((e) => e.type === "fanout-plan") as Extract<RunnerEvent, { type: "fanout-plan" }>;
282
+ expect(plan).toBeDefined();
283
+ expect(plan.companies).toEqual([
284
+ { uid: "cmp_a", slug: "acme" },
285
+ { uid: "cmp_b", slug: "beta" },
286
+ ]);
287
+ });
288
+
289
+ it("degrades to UID when entity.get throws (best-effort slug resolution)", async () => {
290
+ const deps = makeDeps({
291
+ createVaultClient: () =>
292
+ makeVaultStub({
293
+ memberships: [{ companyUid: "cmp_ghost" }],
294
+ entityGet: () => Promise.reject(new Error("entity deleted")),
295
+ }),
296
+ });
297
+
298
+ const code = await runRunner(["--companies"], deps);
299
+ expect(code).toBe(0);
300
+ const plan = deps.stdout
301
+ .events()
302
+ .find((e) => e.type === "fanout-plan") as Extract<RunnerEvent, { type: "fanout-plan" }>;
303
+ expect(plan.companies).toEqual([{ uid: "cmp_ghost", slug: "cmp_ghost" }]);
304
+ });
305
+
306
+ it("degrades to UID when entity.get returns falsy slug", async () => {
307
+ const deps = makeDeps({
308
+ createVaultClient: () =>
309
+ makeVaultStub({
310
+ memberships: [{ companyUid: "cmp_empty" }],
311
+ entityGet: (uid: string) =>
312
+ Promise.resolve({ uid, slug: "" } as unknown as EntityInfo),
313
+ }),
314
+ });
315
+
316
+ const code = await runRunner(["--companies"], deps);
317
+ expect(code).toBe(0);
318
+ const plan = deps.stdout
319
+ .events()
320
+ .find((e) => e.type === "fanout-plan") as Extract<RunnerEvent, { type: "fanout-plan" }>;
321
+ expect(plan.companies).toEqual([{ uid: "cmp_empty", slug: "cmp_empty" }]);
322
+ });
323
+ });
324
+
325
+ // ---------------------------------------------------------------------------
326
+ // per-company event tagging
327
+ // ---------------------------------------------------------------------------
328
+
329
+ describe("per-company fanout", () => {
330
+ it("tags per-file progress events with the company slug", async () => {
331
+ const deps = makeDeps({
332
+ createVaultClient: () =>
333
+ makeVaultStub({
334
+ memberships: [{ companyUid: "cmp_a" }],
335
+ entityGet: (uid: string) =>
336
+ Promise.resolve({ uid, slug: "acme" } as unknown as EntityInfo),
337
+ }),
338
+ sync: vi.fn().mockImplementation(async (opts: SyncOptions) => {
339
+ opts.onEvent?.({ type: "progress", path: "notes.md", bytes: 42 });
340
+ opts.onEvent?.({
341
+ type: "progress",
342
+ path: "shared/doc.md",
343
+ bytes: 1024,
344
+ message: "draft update",
345
+ });
346
+ return defaultSyncResult({ filesDownloaded: 2, bytesDownloaded: 1066 });
347
+ }),
348
+ });
349
+
350
+ const code = await runRunner(["--companies"], deps);
351
+ expect(code).toBe(0);
352
+ const progressEvents = deps.stdout
353
+ .events()
354
+ .filter((e): e is Extract<RunnerEvent, { type: "progress" }> =>
355
+ e.type === "progress",
356
+ );
357
+ expect(progressEvents).toEqual([
358
+ { type: "progress", company: "acme", path: "notes.md", bytes: 42 },
359
+ {
360
+ type: "progress",
361
+ company: "acme",
362
+ path: "shared/doc.md",
363
+ bytes: 1024,
364
+ message: "draft update",
365
+ },
366
+ ]);
367
+ });
368
+
369
+ it("tags per-file error events with the company slug", async () => {
370
+ const deps = makeDeps({
371
+ createVaultClient: () =>
372
+ makeVaultStub({
373
+ memberships: [{ companyUid: "cmp_a" }],
374
+ entityGet: (uid: string) =>
375
+ Promise.resolve({ uid, slug: "acme" } as unknown as EntityInfo),
376
+ }),
377
+ sync: vi.fn().mockImplementation(async (opts: SyncOptions) => {
378
+ opts.onEvent?.({
379
+ type: "error",
380
+ path: "locked.md",
381
+ message: "access denied",
382
+ });
383
+ return defaultSyncResult({ filesSkipped: 1 });
384
+ }),
385
+ });
386
+
387
+ const code = await runRunner(["--companies"], deps);
388
+ expect(code).toBe(0);
389
+ const errs = deps.stdout
390
+ .events()
391
+ .filter((e): e is Extract<RunnerEvent, { type: "error" }> =>
392
+ e.type === "error",
393
+ );
394
+ expect(errs).toEqual([
395
+ {
396
+ type: "error",
397
+ company: "acme",
398
+ path: "locked.md",
399
+ message: "access denied",
400
+ },
401
+ ]);
402
+ });
403
+
404
+ it("emits complete event per company with the SyncResult spread", async () => {
405
+ const result = defaultSyncResult({
406
+ filesDownloaded: 3,
407
+ bytesDownloaded: 999,
408
+ filesSkipped: 1,
409
+ conflicts: 0,
410
+ aborted: false,
411
+ });
412
+ const deps = makeDeps({
413
+ createVaultClient: () =>
414
+ makeVaultStub({
415
+ memberships: [{ companyUid: "cmp_a" }],
416
+ entityGet: (uid: string) =>
417
+ Promise.resolve({ uid, slug: "acme" } as unknown as EntityInfo),
418
+ }),
419
+ sync: vi.fn().mockResolvedValue(result),
420
+ });
421
+
422
+ const code = await runRunner(["--companies"], deps);
423
+ expect(code).toBe(0);
424
+ const complete = deps.stdout
425
+ .events()
426
+ .find((e) => e.type === "complete") as Extract<RunnerEvent, { type: "complete" }>;
427
+ expect(complete).toEqual({
428
+ type: "complete",
429
+ company: "acme",
430
+ ...result,
431
+ });
432
+ });
433
+
434
+ it("passes --on-conflict and --hq-root through to sync()", async () => {
435
+ const syncSpy = vi.fn().mockResolvedValue(defaultSyncResult());
436
+ const deps = makeDeps({
437
+ createVaultClient: () =>
438
+ makeVaultStub({
439
+ memberships: [{ companyUid: "cmp_a" }],
440
+ entityGet: (uid: string) =>
441
+ Promise.resolve({ uid, slug: "acme" } as unknown as EntityInfo),
442
+ }),
443
+ sync: syncSpy,
444
+ });
445
+
446
+ const code = await runRunner(
447
+ [
448
+ "--companies",
449
+ "--on-conflict",
450
+ "overwrite",
451
+ "--hq-root",
452
+ "/tmp/fake-hq",
453
+ ],
454
+ deps,
455
+ );
456
+ expect(code).toBe(0);
457
+ expect(syncSpy).toHaveBeenCalledTimes(1);
458
+ const opts = syncSpy.mock.calls[0][0] as SyncOptions;
459
+ expect(opts.onConflict).toBe("overwrite");
460
+ expect(opts.hqRoot).toBe("/tmp/fake-hq");
461
+ });
462
+
463
+ it("continues the fanout when one company's sync throws", async () => {
464
+ const slugs: Record<string, string> = { cmp_a: "acme", cmp_b: "beta" };
465
+ const deps = makeDeps({
466
+ createVaultClient: () =>
467
+ makeVaultStub({
468
+ memberships: [{ companyUid: "cmp_a" }, { companyUid: "cmp_b" }],
469
+ entityGet: (uid: string) =>
470
+ Promise.resolve({ uid, slug: slugs[uid] ?? uid } as unknown as EntityInfo),
471
+ }),
472
+ sync: vi
473
+ .fn<(opts: SyncOptions) => Promise<SyncResult>>()
474
+ .mockImplementationOnce(async () => {
475
+ throw new Error("acme blew up");
476
+ })
477
+ .mockImplementationOnce(async () =>
478
+ defaultSyncResult({ filesDownloaded: 1, bytesDownloaded: 500 }),
479
+ ),
480
+ });
481
+
482
+ const code = await runRunner(["--companies"], deps);
483
+ expect(code).toBe(0); // whole fanout still returns 0
484
+
485
+ const events = deps.stdout.events();
486
+ // Error event for acme (company-level) with path sentinel "(company)"
487
+ const companyErr = events.find(
488
+ (e): e is Extract<RunnerEvent, { type: "error" }> =>
489
+ e.type === "error" && e.company === "acme",
490
+ );
491
+ expect(companyErr).toMatchObject({
492
+ type: "error",
493
+ company: "acme",
494
+ path: "(company)",
495
+ message: "acme blew up",
496
+ });
497
+ // But beta still completed
498
+ const betaComplete = events.find(
499
+ (e): e is Extract<RunnerEvent, { type: "complete" }> =>
500
+ e.type === "complete" && e.company === "beta",
501
+ );
502
+ expect(betaComplete).toBeDefined();
503
+ expect(betaComplete?.filesDownloaded).toBe(1);
504
+ });
505
+ });
506
+
507
+ // ---------------------------------------------------------------------------
508
+ // all-complete aggregate
509
+ // ---------------------------------------------------------------------------
510
+
511
+ describe("all-complete aggregate", () => {
512
+ it("sums filesDownloaded and bytesDownloaded across all companies", async () => {
513
+ const slugs: Record<string, string> = { cmp_a: "acme", cmp_b: "beta" };
514
+ const deps = makeDeps({
515
+ createVaultClient: () =>
516
+ makeVaultStub({
517
+ memberships: [{ companyUid: "cmp_a" }, { companyUid: "cmp_b" }],
518
+ entityGet: (uid: string) =>
519
+ Promise.resolve({ uid, slug: slugs[uid] ?? uid } as unknown as EntityInfo),
520
+ }),
521
+ sync: vi
522
+ .fn<(opts: SyncOptions) => Promise<SyncResult>>()
523
+ .mockResolvedValueOnce(
524
+ defaultSyncResult({ filesDownloaded: 3, bytesDownloaded: 100 }),
525
+ )
526
+ .mockResolvedValueOnce(
527
+ defaultSyncResult({ filesDownloaded: 4, bytesDownloaded: 250 }),
528
+ ),
529
+ });
530
+
531
+ const code = await runRunner(["--companies"], deps);
532
+ expect(code).toBe(0);
533
+ const all = deps.stdout
534
+ .events()
535
+ .find((e) => e.type === "all-complete") as Extract<RunnerEvent, { type: "all-complete" }>;
536
+ expect(all).toEqual({
537
+ type: "all-complete",
538
+ companiesAttempted: 2,
539
+ filesDownloaded: 7,
540
+ bytesDownloaded: 350,
541
+ errors: [],
542
+ });
543
+ });
544
+
545
+ it("collects company-level errors into the all-complete errors array", async () => {
546
+ const slugs: Record<string, string> = { cmp_a: "acme", cmp_b: "beta" };
547
+ const deps = makeDeps({
548
+ createVaultClient: () =>
549
+ makeVaultStub({
550
+ memberships: [{ companyUid: "cmp_a" }, { companyUid: "cmp_b" }],
551
+ entityGet: (uid: string) =>
552
+ Promise.resolve({ uid, slug: slugs[uid] ?? uid } as unknown as EntityInfo),
553
+ }),
554
+ sync: vi
555
+ .fn<(opts: SyncOptions) => Promise<SyncResult>>()
556
+ .mockRejectedValueOnce(new Error("acme failed"))
557
+ .mockResolvedValueOnce(defaultSyncResult()),
558
+ });
559
+
560
+ const code = await runRunner(["--companies"], deps);
561
+ expect(code).toBe(0);
562
+ const all = deps.stdout
563
+ .events()
564
+ .find((e) => e.type === "all-complete") as Extract<RunnerEvent, { type: "all-complete" }>;
565
+ expect(all.companiesAttempted).toBe(2);
566
+ expect(all.errors).toEqual([
567
+ { company: "acme", message: "acme failed" },
568
+ ]);
569
+ });
570
+ });
571
+
572
+ // ---------------------------------------------------------------------------
573
+ // ndjson stream shape (belt-and-suspenders)
574
+ // ---------------------------------------------------------------------------
575
+
576
+ describe("ndjson stream shape", () => {
577
+ it("emits one JSON object per line, terminated by newline", async () => {
578
+ const deps = makeDeps({
579
+ createVaultClient: () =>
580
+ makeVaultStub({
581
+ memberships: [{ companyUid: "cmp_a" }],
582
+ entityGet: (uid: string) =>
583
+ Promise.resolve({ uid, slug: "acme" } as unknown as EntityInfo),
584
+ }),
585
+ sync: vi.fn().mockImplementation(async (opts: SyncOptions) => {
586
+ opts.onEvent?.({ type: "progress", path: "x.md", bytes: 1 });
587
+ return defaultSyncResult({ filesDownloaded: 1, bytesDownloaded: 1 });
588
+ }),
589
+ });
590
+
591
+ await runRunner(["--companies"], deps);
592
+ const raw = deps.stdout.raw();
593
+ expect(raw.endsWith("\n")).toBe(true);
594
+ // Every line must parse as JSON
595
+ const lines = raw.split("\n").filter((l) => l.length > 0);
596
+ for (const line of lines) {
597
+ expect(() => JSON.parse(line)).not.toThrow();
598
+ }
599
+ // Expected shape: fanout-plan, progress, complete, all-complete
600
+ expect(lines).toHaveLength(4);
601
+ const types = lines.map((l) => (JSON.parse(l) as RunnerEvent).type);
602
+ expect(types).toEqual([
603
+ "fanout-plan",
604
+ "progress",
605
+ "complete",
606
+ "all-complete",
607
+ ]);
608
+ });
609
+ });
610
+
611
+ // ---------------------------------------------------------------------------
612
+ // Re-initialize for each test (mock state hygiene)
613
+ // ---------------------------------------------------------------------------
614
+
615
+ beforeEach(() => {
616
+ vi.clearAllMocks();
617
+ });