@indigoai-us/hq-cloud 5.1.0 → 5.1.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin/sync-runner.d.ts +134 -0
- package/dist/bin/sync-runner.d.ts.map +1 -0
- package/dist/bin/sync-runner.js +360 -0
- package/dist/bin/sync-runner.js.map +1 -0
- package/dist/bin/sync-runner.test.d.ts +10 -0
- package/dist/bin/sync-runner.test.d.ts.map +1 -0
- package/dist/bin/sync-runner.test.js +648 -0
- package/dist/bin/sync-runner.test.js.map +1 -0
- package/dist/cli/index.d.ts +1 -1
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/share.js +2 -2
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/share.test.js +9 -1
- package/dist/cli/share.test.js.map +1 -1
- package/dist/cli/sync.d.ts +28 -0
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +33 -10
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +15 -4
- package/dist/cli/sync.test.js.map +1 -1
- package/dist/cognito-auth.d.ts.map +1 -1
- package/dist/cognito-auth.js +19 -1
- package/dist/cognito-auth.js.map +1 -1
- package/dist/cognito-auth.test.d.ts +9 -0
- package/dist/cognito-auth.test.d.ts.map +1 -0
- package/dist/cognito-auth.test.js +113 -0
- package/dist/cognito-auth.test.js.map +1 -0
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +1 -0
- package/dist/context.js.map +1 -1
- package/dist/daemon-worker.d.ts +6 -1
- package/dist/daemon-worker.d.ts.map +1 -1
- package/dist/daemon-worker.js +12 -16
- package/dist/daemon-worker.js.map +1 -1
- package/dist/daemon.d.ts +2 -0
- package/dist/daemon.d.ts.map +1 -1
- package/dist/daemon.js +2 -0
- package/dist/daemon.js.map +1 -1
- package/dist/ignore.d.ts +13 -2
- package/dist/ignore.d.ts.map +1 -1
- package/dist/ignore.js +69 -12
- package/dist/ignore.js.map +1 -1
- package/dist/index.d.ts +24 -28
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +19 -134
- package/dist/index.js.map +1 -1
- package/dist/journal.d.ts +20 -4
- package/dist/journal.d.ts.map +1 -1
- package/dist/journal.js +45 -8
- package/dist/journal.js.map +1 -1
- package/dist/journal.test.d.ts +9 -0
- package/dist/journal.test.d.ts.map +1 -0
- package/dist/journal.test.js +114 -0
- package/dist/journal.test.js.map +1 -0
- package/dist/s3.d.ts +18 -6
- package/dist/s3.d.ts.map +1 -1
- package/dist/s3.js +57 -56
- package/dist/s3.js.map +1 -1
- package/dist/types.d.ts +34 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/vault-client.d.ts +59 -0
- package/dist/vault-client.d.ts.map +1 -1
- package/dist/vault-client.js +72 -0
- package/dist/vault-client.js.map +1 -1
- package/dist/vault-client.test.js +160 -0
- package/dist/vault-client.test.js.map +1 -1
- package/dist/watcher.d.ts +7 -1
- package/dist/watcher.d.ts.map +1 -1
- package/dist/watcher.js +11 -5
- package/dist/watcher.js.map +1 -1
- package/package.json +15 -3
- package/src/bin/sync-runner.test.ts +804 -0
- package/src/bin/sync-runner.ts +499 -0
- package/src/cli/accept.ts +97 -0
- package/src/cli/conflict.ts +119 -0
- package/src/cli/index.ts +25 -0
- package/src/cli/invite.test.ts +247 -0
- package/src/cli/invite.ts +180 -0
- package/src/cli/promote.ts +123 -0
- package/src/cli/share.test.ts +155 -0
- package/src/cli/share.ts +212 -0
- package/src/cli/sync.test.ts +225 -0
- package/src/cli/sync.ts +225 -0
- package/src/cognito-auth.test.ts +156 -0
- package/src/cognito-auth.ts +18 -1
- package/src/context.test.ts +202 -0
- package/src/context.ts +178 -0
- package/src/daemon-worker.ts +13 -19
- package/src/daemon.ts +2 -0
- package/src/ignore.ts +76 -12
- package/src/index.ts +94 -165
- package/src/journal.test.ts +146 -0
- package/src/journal.ts +53 -11
- package/src/s3.ts +76 -66
- package/src/types.ts +37 -0
- package/src/vault-client.test.ts +563 -0
- package/src/vault-client.ts +478 -0
- package/src/watcher.ts +12 -5
- package/test/invite-flow.integration.test.ts +244 -0
- package/test/share-sync.integration.test.ts +210 -0
|
@@ -0,0 +1,804 @@
|
|
|
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 {
|
|
19
|
+
Membership,
|
|
20
|
+
EntityInfo,
|
|
21
|
+
PendingInviteByEmail,
|
|
22
|
+
} from "../vault-client.js";
|
|
23
|
+
import { VaultAuthError } from "../vault-client.js";
|
|
24
|
+
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
// Capturing writer — collects writes so we can assert on the ndjson stream
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
interface CapturingWriter {
|
|
30
|
+
write: (chunk: string) => boolean;
|
|
31
|
+
lines: () => string[];
|
|
32
|
+
events: () => RunnerEvent[];
|
|
33
|
+
raw: () => string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function makeWriter(): CapturingWriter {
|
|
37
|
+
let buf = "";
|
|
38
|
+
return {
|
|
39
|
+
write: (chunk: string) => {
|
|
40
|
+
buf += chunk;
|
|
41
|
+
return true;
|
|
42
|
+
},
|
|
43
|
+
lines: () => buf.split("\n").filter((l) => l.length > 0),
|
|
44
|
+
events: () =>
|
|
45
|
+
buf
|
|
46
|
+
.split("\n")
|
|
47
|
+
.filter((l) => l.length > 0)
|
|
48
|
+
.map((l) => JSON.parse(l) as RunnerEvent),
|
|
49
|
+
raw: () => buf,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
// Default stub factory — tests override individual fields
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
function defaultSyncResult(overrides: Partial<SyncResult> = {}): SyncResult {
|
|
58
|
+
return {
|
|
59
|
+
filesDownloaded: 0,
|
|
60
|
+
bytesDownloaded: 0,
|
|
61
|
+
filesSkipped: 0,
|
|
62
|
+
conflicts: 0,
|
|
63
|
+
aborted: false,
|
|
64
|
+
...overrides,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Produce a minimal VaultClientSurface stub. Tests pass in the memberships
|
|
70
|
+
* they want `listMyMemberships` to return, plus the sequence of `entity.get`
|
|
71
|
+
* resolutions. Defaults cover the "no memberships" path.
|
|
72
|
+
*/
|
|
73
|
+
function makeVaultStub(
|
|
74
|
+
opts: {
|
|
75
|
+
memberships?: Array<Pick<Membership, "companyUid">>;
|
|
76
|
+
entityGet?: (uid: string) => Promise<EntityInfo>;
|
|
77
|
+
pendingInvites?: Array<Record<string, unknown>>;
|
|
78
|
+
ensurePerson?: (hints: {
|
|
79
|
+
ownerSub: string;
|
|
80
|
+
displayName: string;
|
|
81
|
+
}) => Promise<EntityInfo>;
|
|
82
|
+
claim?: (personUid: string) => Promise<void>;
|
|
83
|
+
} = {},
|
|
84
|
+
): VaultClientSurface {
|
|
85
|
+
const memberships = opts.memberships ?? [];
|
|
86
|
+
const pending = opts.pendingInvites ?? [];
|
|
87
|
+
return {
|
|
88
|
+
listMyMemberships: () => Promise.resolve(memberships as Membership[]),
|
|
89
|
+
listMyPendingInvitesByEmail: () =>
|
|
90
|
+
Promise.resolve(pending as unknown as PendingInviteByEmail[]),
|
|
91
|
+
claimPendingInvitesByEmail:
|
|
92
|
+
opts.claim ?? (() => Promise.resolve(undefined)),
|
|
93
|
+
ensureMyPersonEntity:
|
|
94
|
+
opts.ensurePerson ??
|
|
95
|
+
(() =>
|
|
96
|
+
Promise.resolve({
|
|
97
|
+
uid: "ent_person_default",
|
|
98
|
+
type: "person",
|
|
99
|
+
slug: "default-person",
|
|
100
|
+
status: "active",
|
|
101
|
+
} as unknown as EntityInfo)),
|
|
102
|
+
entity: {
|
|
103
|
+
get:
|
|
104
|
+
opts.entityGet ??
|
|
105
|
+
((uid: string) =>
|
|
106
|
+
Promise.resolve({
|
|
107
|
+
uid,
|
|
108
|
+
type: "company",
|
|
109
|
+
slug: uid,
|
|
110
|
+
bucketName: `bucket-${uid}`,
|
|
111
|
+
status: "active",
|
|
112
|
+
} as unknown as EntityInfo)),
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
interface TestDeps extends RunnerDeps {
|
|
118
|
+
stdout: CapturingWriter;
|
|
119
|
+
stderr: CapturingWriter;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function makeDeps(overrides: Partial<RunnerDeps> = {}): TestDeps {
|
|
123
|
+
const stdout = makeWriter();
|
|
124
|
+
const stderr = makeWriter();
|
|
125
|
+
// Spread overrides first so our CapturingWriter stdout/stderr always
|
|
126
|
+
// survive in the returned shape. Tests cannot override those — capturing
|
|
127
|
+
// is the whole point of the helper. vi.fn() wraps defaults so tests can
|
|
128
|
+
// still call .toHaveBeenCalled() / .toHaveBeenCalledTimes() on the returned
|
|
129
|
+
// deps without each override re-wrapping.
|
|
130
|
+
return {
|
|
131
|
+
getAccessToken: vi.fn().mockResolvedValue("test-access-token"),
|
|
132
|
+
createVaultClient: vi.fn().mockImplementation(() => makeVaultStub()),
|
|
133
|
+
sync: vi.fn().mockResolvedValue(defaultSyncResult()),
|
|
134
|
+
...overrides,
|
|
135
|
+
stdout,
|
|
136
|
+
stderr,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ---------------------------------------------------------------------------
|
|
141
|
+
// argv parsing
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
|
|
144
|
+
describe("argv parsing", () => {
|
|
145
|
+
it("rejects missing mode with exit 1", async () => {
|
|
146
|
+
const deps = makeDeps();
|
|
147
|
+
const code = await runRunner([], deps);
|
|
148
|
+
expect(code).toBe(1);
|
|
149
|
+
expect(deps.stderr.raw()).toContain("--companies or --company");
|
|
150
|
+
expect(deps.stdout.events()).toEqual([]);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("rejects --companies + --company together", async () => {
|
|
154
|
+
const deps = makeDeps();
|
|
155
|
+
const code = await runRunner(["--companies", "--company", "acme"], deps);
|
|
156
|
+
expect(code).toBe(1);
|
|
157
|
+
expect(deps.stderr.raw()).toContain("not both");
|
|
158
|
+
expect(deps.stdout.events()).toEqual([]);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("rejects unknown flags", async () => {
|
|
162
|
+
const deps = makeDeps();
|
|
163
|
+
const code = await runRunner(["--companies", "--wat"], deps);
|
|
164
|
+
expect(code).toBe(1);
|
|
165
|
+
expect(deps.stderr.raw()).toContain("Unknown argument: --wat");
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("rejects invalid --on-conflict value", async () => {
|
|
169
|
+
const deps = makeDeps();
|
|
170
|
+
const code = await runRunner(
|
|
171
|
+
["--companies", "--on-conflict", "nuke"],
|
|
172
|
+
deps,
|
|
173
|
+
);
|
|
174
|
+
expect(code).toBe(1);
|
|
175
|
+
expect(deps.stderr.raw()).toContain("abort|overwrite|keep");
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("accepts --json as a silent no-op (ndjson is the only mode)", async () => {
|
|
179
|
+
const deps = makeDeps({
|
|
180
|
+
createVaultClient: () => makeVaultStub({ memberships: [] }),
|
|
181
|
+
});
|
|
182
|
+
const code = await runRunner(["--companies", "--json"], deps);
|
|
183
|
+
expect(code).toBe(0);
|
|
184
|
+
// Empty memberships → setup-needed, not a parse error
|
|
185
|
+
expect(deps.stdout.events()).toEqual([{ type: "setup-needed" }]);
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
// ---------------------------------------------------------------------------
|
|
190
|
+
// auth
|
|
191
|
+
// ---------------------------------------------------------------------------
|
|
192
|
+
|
|
193
|
+
describe("auth", () => {
|
|
194
|
+
it("emits auth-error and returns 0 when token fetch fails", async () => {
|
|
195
|
+
const deps = makeDeps({
|
|
196
|
+
getAccessToken: vi.fn().mockRejectedValue(new Error("no cached tokens")),
|
|
197
|
+
});
|
|
198
|
+
const code = await runRunner(["--companies"], deps);
|
|
199
|
+
expect(code).toBe(0);
|
|
200
|
+
expect(deps.stdout.events()).toEqual([
|
|
201
|
+
{ type: "auth-error", message: "no cached tokens" },
|
|
202
|
+
]);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("emits auth-error when VaultAuthError thrown during discovery", async () => {
|
|
206
|
+
const deps = makeDeps({
|
|
207
|
+
createVaultClient: () => ({
|
|
208
|
+
...makeVaultStub(),
|
|
209
|
+
listMyMemberships: () =>
|
|
210
|
+
Promise.reject(new VaultAuthError("token expired")),
|
|
211
|
+
}),
|
|
212
|
+
});
|
|
213
|
+
const code = await runRunner(["--companies"], deps);
|
|
214
|
+
expect(code).toBe(0);
|
|
215
|
+
expect(deps.stdout.events()).toEqual([
|
|
216
|
+
{ type: "auth-error", message: "token expired" },
|
|
217
|
+
]);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it("emits error event and returns 1 on non-auth discovery failure", async () => {
|
|
221
|
+
const deps = makeDeps({
|
|
222
|
+
createVaultClient: () => ({
|
|
223
|
+
...makeVaultStub(),
|
|
224
|
+
listMyMemberships: () => Promise.reject(new Error("network down")),
|
|
225
|
+
}),
|
|
226
|
+
});
|
|
227
|
+
const code = await runRunner(["--companies"], deps);
|
|
228
|
+
expect(code).toBe(1);
|
|
229
|
+
const events = deps.stdout.events();
|
|
230
|
+
expect(events).toHaveLength(1);
|
|
231
|
+
expect(events[0]).toMatchObject({
|
|
232
|
+
type: "error",
|
|
233
|
+
message: "network down",
|
|
234
|
+
path: "(discovery)",
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
// ---------------------------------------------------------------------------
|
|
240
|
+
// claim-dance (first sign-in)
|
|
241
|
+
// ---------------------------------------------------------------------------
|
|
242
|
+
|
|
243
|
+
describe("claim-dance", () => {
|
|
244
|
+
const claims = {
|
|
245
|
+
sub: "sub-abc",
|
|
246
|
+
email: "stefan@getindigo.ai",
|
|
247
|
+
name: "Stefan Johnson",
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
it("claims pending invites + ensures person before listing memberships", async () => {
|
|
251
|
+
const ensureSpy = vi.fn().mockResolvedValue({
|
|
252
|
+
uid: "ent_person_stefan",
|
|
253
|
+
type: "person",
|
|
254
|
+
slug: "stefan-johnson",
|
|
255
|
+
status: "active",
|
|
256
|
+
});
|
|
257
|
+
const claimSpy = vi.fn().mockResolvedValue(undefined);
|
|
258
|
+
// First listMyMemberships returns the just-claimed row.
|
|
259
|
+
let listCalls = 0;
|
|
260
|
+
const stub = makeVaultStub({
|
|
261
|
+
pendingInvites: [
|
|
262
|
+
{
|
|
263
|
+
membershipKey: "inv_1",
|
|
264
|
+
companyUid: "cmp_indigo",
|
|
265
|
+
role: "owner",
|
|
266
|
+
invitedBy: "sub-admin",
|
|
267
|
+
invitedAt: "2026-04-20T00:00:00Z",
|
|
268
|
+
},
|
|
269
|
+
],
|
|
270
|
+
ensurePerson: ensureSpy as unknown as VaultClientSurface["ensureMyPersonEntity"],
|
|
271
|
+
claim: claimSpy as unknown as VaultClientSurface["claimPendingInvitesByEmail"],
|
|
272
|
+
});
|
|
273
|
+
stub.listMyMemberships = () => {
|
|
274
|
+
listCalls++;
|
|
275
|
+
return Promise.resolve([{ companyUid: "cmp_indigo" }] as Membership[]);
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
const deps = makeDeps({
|
|
279
|
+
createVaultClient: () => stub,
|
|
280
|
+
getIdTokenClaims: () => claims,
|
|
281
|
+
});
|
|
282
|
+
const code = await runRunner(["--companies"], deps);
|
|
283
|
+
expect(code).toBe(0);
|
|
284
|
+
expect(ensureSpy).toHaveBeenCalledWith({
|
|
285
|
+
ownerSub: "sub-abc",
|
|
286
|
+
displayName: "Stefan Johnson",
|
|
287
|
+
});
|
|
288
|
+
expect(claimSpy).toHaveBeenCalledWith("ent_person_stefan");
|
|
289
|
+
expect(listCalls).toBe(1);
|
|
290
|
+
// setup-needed must NOT fire — the user has memberships after the claim.
|
|
291
|
+
expect(deps.stdout.events().some((e) => e.type === "setup-needed")).toBe(
|
|
292
|
+
false,
|
|
293
|
+
);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it("skips ensurePerson + claim when no pending invites exist", async () => {
|
|
297
|
+
const ensureSpy = vi.fn();
|
|
298
|
+
const claimSpy = vi.fn();
|
|
299
|
+
const deps = makeDeps({
|
|
300
|
+
createVaultClient: () =>
|
|
301
|
+
makeVaultStub({
|
|
302
|
+
pendingInvites: [],
|
|
303
|
+
ensurePerson:
|
|
304
|
+
ensureSpy as unknown as VaultClientSurface["ensureMyPersonEntity"],
|
|
305
|
+
claim: claimSpy as unknown as VaultClientSurface["claimPendingInvitesByEmail"],
|
|
306
|
+
}),
|
|
307
|
+
getIdTokenClaims: () => claims,
|
|
308
|
+
});
|
|
309
|
+
const code = await runRunner(["--companies"], deps);
|
|
310
|
+
expect(code).toBe(0);
|
|
311
|
+
expect(ensureSpy).not.toHaveBeenCalled();
|
|
312
|
+
expect(claimSpy).not.toHaveBeenCalled();
|
|
313
|
+
// No memberships, no invites — truly empty → setup-needed is correct here.
|
|
314
|
+
expect(deps.stdout.events()).toEqual([{ type: "setup-needed" }]);
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
it("skips claim-dance entirely when no idToken claims are available", async () => {
|
|
318
|
+
const pendingSpy = vi.fn().mockResolvedValue([]);
|
|
319
|
+
const stub = makeVaultStub();
|
|
320
|
+
stub.listMyPendingInvitesByEmail =
|
|
321
|
+
pendingSpy as unknown as VaultClientSurface["listMyPendingInvitesByEmail"];
|
|
322
|
+
const deps = makeDeps({
|
|
323
|
+
createVaultClient: () => stub,
|
|
324
|
+
getIdTokenClaims: () => null,
|
|
325
|
+
});
|
|
326
|
+
await runRunner(["--companies"], deps);
|
|
327
|
+
expect(pendingSpy).not.toHaveBeenCalled();
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
it("does not fail the run when claim-dance throws (best-effort)", async () => {
|
|
331
|
+
const stub = makeVaultStub({
|
|
332
|
+
memberships: [{ companyUid: "cmp_a" }],
|
|
333
|
+
});
|
|
334
|
+
stub.listMyPendingInvitesByEmail = () =>
|
|
335
|
+
Promise.reject(new Error("vault 500"));
|
|
336
|
+
const deps = makeDeps({
|
|
337
|
+
createVaultClient: () => stub,
|
|
338
|
+
getIdTokenClaims: () => claims,
|
|
339
|
+
});
|
|
340
|
+
const code = await runRunner(["--companies"], deps);
|
|
341
|
+
expect(code).toBe(0);
|
|
342
|
+
// Sync proceeds as usual for the existing membership.
|
|
343
|
+
expect(deps.sync).toHaveBeenCalledTimes(1);
|
|
344
|
+
expect(deps.stderr.raw()).toContain("claim-dance skipped");
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
it("falls back to given_name + family_name when name claim is absent", async () => {
|
|
348
|
+
const ensureSpy = vi.fn().mockResolvedValue({
|
|
349
|
+
uid: "ent_person_x",
|
|
350
|
+
type: "person",
|
|
351
|
+
slug: "x",
|
|
352
|
+
status: "active",
|
|
353
|
+
});
|
|
354
|
+
const deps = makeDeps({
|
|
355
|
+
createVaultClient: () =>
|
|
356
|
+
makeVaultStub({
|
|
357
|
+
pendingInvites: [
|
|
358
|
+
{
|
|
359
|
+
membershipKey: "inv_1",
|
|
360
|
+
companyUid: "cmp_x",
|
|
361
|
+
role: "owner",
|
|
362
|
+
invitedBy: "sub-admin",
|
|
363
|
+
invitedAt: "2026-04-20T00:00:00Z",
|
|
364
|
+
},
|
|
365
|
+
],
|
|
366
|
+
ensurePerson:
|
|
367
|
+
ensureSpy as unknown as VaultClientSurface["ensureMyPersonEntity"],
|
|
368
|
+
}),
|
|
369
|
+
getIdTokenClaims: () => ({
|
|
370
|
+
sub: "sub-xyz",
|
|
371
|
+
given_name: "Ada",
|
|
372
|
+
family_name: "Lovelace",
|
|
373
|
+
}),
|
|
374
|
+
});
|
|
375
|
+
await runRunner(["--companies"], deps);
|
|
376
|
+
expect(ensureSpy).toHaveBeenCalledWith({
|
|
377
|
+
ownerSub: "sub-xyz",
|
|
378
|
+
displayName: "Ada Lovelace",
|
|
379
|
+
});
|
|
380
|
+
});
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
// ---------------------------------------------------------------------------
|
|
384
|
+
// target resolution
|
|
385
|
+
// ---------------------------------------------------------------------------
|
|
386
|
+
|
|
387
|
+
describe("target resolution", () => {
|
|
388
|
+
it("emits setup-needed when --companies returns no memberships", async () => {
|
|
389
|
+
const deps = makeDeps();
|
|
390
|
+
const code = await runRunner(["--companies"], deps);
|
|
391
|
+
expect(code).toBe(0);
|
|
392
|
+
expect(deps.stdout.events()).toEqual([{ type: "setup-needed" }]);
|
|
393
|
+
// sync should NOT have been called — no targets
|
|
394
|
+
expect(deps.sync).not.toHaveBeenCalled();
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
it("single-company mode skips listMyMemberships and syncs the named UID", async () => {
|
|
398
|
+
const listSpy = vi.fn();
|
|
399
|
+
const deps = makeDeps({
|
|
400
|
+
createVaultClient: () => ({
|
|
401
|
+
...makeVaultStub({
|
|
402
|
+
entityGet: (uid: string) =>
|
|
403
|
+
Promise.resolve({ uid, slug: "acme" } as unknown as EntityInfo),
|
|
404
|
+
}),
|
|
405
|
+
listMyMemberships: listSpy as unknown as () => Promise<Membership[]>,
|
|
406
|
+
}),
|
|
407
|
+
});
|
|
408
|
+
const code = await runRunner(["--company", "cmp_abc"], deps);
|
|
409
|
+
expect(code).toBe(0);
|
|
410
|
+
expect(listSpy).not.toHaveBeenCalled();
|
|
411
|
+
expect(deps.sync).toHaveBeenCalledTimes(1);
|
|
412
|
+
const call = (deps.sync as ReturnType<typeof vi.fn>).mock.calls[0][0] as SyncOptions;
|
|
413
|
+
expect(call.company).toBe("cmp_abc");
|
|
414
|
+
});
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
// ---------------------------------------------------------------------------
|
|
418
|
+
// fanout-plan
|
|
419
|
+
// ---------------------------------------------------------------------------
|
|
420
|
+
|
|
421
|
+
describe("fanout-plan", () => {
|
|
422
|
+
it("resolves slugs from entity.get before fanning out", async () => {
|
|
423
|
+
const slugByUid: Record<string, string> = {
|
|
424
|
+
cmp_a: "acme",
|
|
425
|
+
cmp_b: "beta",
|
|
426
|
+
};
|
|
427
|
+
const deps = makeDeps({
|
|
428
|
+
createVaultClient: () =>
|
|
429
|
+
makeVaultStub({
|
|
430
|
+
memberships: [{ companyUid: "cmp_a" }, { companyUid: "cmp_b" }],
|
|
431
|
+
entityGet: (uid: string) =>
|
|
432
|
+
Promise.resolve({
|
|
433
|
+
uid,
|
|
434
|
+
slug: slugByUid[uid] ?? uid,
|
|
435
|
+
} as unknown as EntityInfo),
|
|
436
|
+
}),
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
const code = await runRunner(["--companies"], deps);
|
|
440
|
+
expect(code).toBe(0);
|
|
441
|
+
const plan = deps.stdout
|
|
442
|
+
.events()
|
|
443
|
+
.find((e) => e.type === "fanout-plan") as Extract<RunnerEvent, { type: "fanout-plan" }>;
|
|
444
|
+
expect(plan).toBeDefined();
|
|
445
|
+
expect(plan.companies).toEqual([
|
|
446
|
+
{ uid: "cmp_a", slug: "acme" },
|
|
447
|
+
{ uid: "cmp_b", slug: "beta" },
|
|
448
|
+
]);
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
it("degrades to UID when entity.get throws (best-effort slug resolution)", async () => {
|
|
452
|
+
const deps = makeDeps({
|
|
453
|
+
createVaultClient: () =>
|
|
454
|
+
makeVaultStub({
|
|
455
|
+
memberships: [{ companyUid: "cmp_ghost" }],
|
|
456
|
+
entityGet: () => Promise.reject(new Error("entity deleted")),
|
|
457
|
+
}),
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
const code = await runRunner(["--companies"], deps);
|
|
461
|
+
expect(code).toBe(0);
|
|
462
|
+
const plan = deps.stdout
|
|
463
|
+
.events()
|
|
464
|
+
.find((e) => e.type === "fanout-plan") as Extract<RunnerEvent, { type: "fanout-plan" }>;
|
|
465
|
+
expect(plan.companies).toEqual([{ uid: "cmp_ghost", slug: "cmp_ghost" }]);
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
it("includes entity.name on plan entries when available", async () => {
|
|
469
|
+
const deps = makeDeps({
|
|
470
|
+
createVaultClient: () =>
|
|
471
|
+
makeVaultStub({
|
|
472
|
+
memberships: [{ companyUid: "cmp_a" }, { companyUid: "cmp_b" }],
|
|
473
|
+
entityGet: (uid: string) =>
|
|
474
|
+
Promise.resolve({
|
|
475
|
+
uid,
|
|
476
|
+
slug: uid === "cmp_a" ? "acme" : "beta",
|
|
477
|
+
name: uid === "cmp_a" ? "Acme Corp" : undefined,
|
|
478
|
+
} as unknown as EntityInfo),
|
|
479
|
+
}),
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
const code = await runRunner(["--companies"], deps);
|
|
483
|
+
expect(code).toBe(0);
|
|
484
|
+
const plan = deps.stdout
|
|
485
|
+
.events()
|
|
486
|
+
.find((e) => e.type === "fanout-plan") as Extract<RunnerEvent, { type: "fanout-plan" }>;
|
|
487
|
+
expect(plan.companies).toEqual([
|
|
488
|
+
{ uid: "cmp_a", slug: "acme", name: "Acme Corp" },
|
|
489
|
+
{ uid: "cmp_b", slug: "beta" },
|
|
490
|
+
]);
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
it("degrades to UID when entity.get returns falsy slug", async () => {
|
|
494
|
+
const deps = makeDeps({
|
|
495
|
+
createVaultClient: () =>
|
|
496
|
+
makeVaultStub({
|
|
497
|
+
memberships: [{ companyUid: "cmp_empty" }],
|
|
498
|
+
entityGet: (uid: string) =>
|
|
499
|
+
Promise.resolve({ uid, slug: "" } as unknown as EntityInfo),
|
|
500
|
+
}),
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
const code = await runRunner(["--companies"], deps);
|
|
504
|
+
expect(code).toBe(0);
|
|
505
|
+
const plan = deps.stdout
|
|
506
|
+
.events()
|
|
507
|
+
.find((e) => e.type === "fanout-plan") as Extract<RunnerEvent, { type: "fanout-plan" }>;
|
|
508
|
+
expect(plan.companies).toEqual([{ uid: "cmp_empty", slug: "cmp_empty" }]);
|
|
509
|
+
});
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
// ---------------------------------------------------------------------------
|
|
513
|
+
// per-company event tagging
|
|
514
|
+
// ---------------------------------------------------------------------------
|
|
515
|
+
|
|
516
|
+
describe("per-company fanout", () => {
|
|
517
|
+
it("tags per-file progress events with the company slug", async () => {
|
|
518
|
+
const deps = makeDeps({
|
|
519
|
+
createVaultClient: () =>
|
|
520
|
+
makeVaultStub({
|
|
521
|
+
memberships: [{ companyUid: "cmp_a" }],
|
|
522
|
+
entityGet: (uid: string) =>
|
|
523
|
+
Promise.resolve({ uid, slug: "acme" } as unknown as EntityInfo),
|
|
524
|
+
}),
|
|
525
|
+
sync: vi.fn().mockImplementation(async (opts: SyncOptions) => {
|
|
526
|
+
opts.onEvent?.({ type: "progress", path: "notes.md", bytes: 42 });
|
|
527
|
+
opts.onEvent?.({
|
|
528
|
+
type: "progress",
|
|
529
|
+
path: "shared/doc.md",
|
|
530
|
+
bytes: 1024,
|
|
531
|
+
message: "draft update",
|
|
532
|
+
});
|
|
533
|
+
return defaultSyncResult({ filesDownloaded: 2, bytesDownloaded: 1066 });
|
|
534
|
+
}),
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
const code = await runRunner(["--companies"], deps);
|
|
538
|
+
expect(code).toBe(0);
|
|
539
|
+
const progressEvents = deps.stdout
|
|
540
|
+
.events()
|
|
541
|
+
.filter((e): e is Extract<RunnerEvent, { type: "progress" }> =>
|
|
542
|
+
e.type === "progress",
|
|
543
|
+
);
|
|
544
|
+
expect(progressEvents).toEqual([
|
|
545
|
+
{ type: "progress", company: "acme", path: "notes.md", bytes: 42 },
|
|
546
|
+
{
|
|
547
|
+
type: "progress",
|
|
548
|
+
company: "acme",
|
|
549
|
+
path: "shared/doc.md",
|
|
550
|
+
bytes: 1024,
|
|
551
|
+
message: "draft update",
|
|
552
|
+
},
|
|
553
|
+
]);
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
it("tags per-file error events with the company slug", async () => {
|
|
557
|
+
const deps = makeDeps({
|
|
558
|
+
createVaultClient: () =>
|
|
559
|
+
makeVaultStub({
|
|
560
|
+
memberships: [{ companyUid: "cmp_a" }],
|
|
561
|
+
entityGet: (uid: string) =>
|
|
562
|
+
Promise.resolve({ uid, slug: "acme" } as unknown as EntityInfo),
|
|
563
|
+
}),
|
|
564
|
+
sync: vi.fn().mockImplementation(async (opts: SyncOptions) => {
|
|
565
|
+
opts.onEvent?.({
|
|
566
|
+
type: "error",
|
|
567
|
+
path: "locked.md",
|
|
568
|
+
message: "access denied",
|
|
569
|
+
});
|
|
570
|
+
return defaultSyncResult({ filesSkipped: 1 });
|
|
571
|
+
}),
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
const code = await runRunner(["--companies"], deps);
|
|
575
|
+
expect(code).toBe(0);
|
|
576
|
+
const errs = deps.stdout
|
|
577
|
+
.events()
|
|
578
|
+
.filter((e): e is Extract<RunnerEvent, { type: "error" }> =>
|
|
579
|
+
e.type === "error",
|
|
580
|
+
);
|
|
581
|
+
expect(errs).toEqual([
|
|
582
|
+
{
|
|
583
|
+
type: "error",
|
|
584
|
+
company: "acme",
|
|
585
|
+
path: "locked.md",
|
|
586
|
+
message: "access denied",
|
|
587
|
+
},
|
|
588
|
+
]);
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
it("emits complete event per company with the SyncResult spread", async () => {
|
|
592
|
+
const result = defaultSyncResult({
|
|
593
|
+
filesDownloaded: 3,
|
|
594
|
+
bytesDownloaded: 999,
|
|
595
|
+
filesSkipped: 1,
|
|
596
|
+
conflicts: 0,
|
|
597
|
+
aborted: false,
|
|
598
|
+
});
|
|
599
|
+
const deps = makeDeps({
|
|
600
|
+
createVaultClient: () =>
|
|
601
|
+
makeVaultStub({
|
|
602
|
+
memberships: [{ companyUid: "cmp_a" }],
|
|
603
|
+
entityGet: (uid: string) =>
|
|
604
|
+
Promise.resolve({ uid, slug: "acme" } as unknown as EntityInfo),
|
|
605
|
+
}),
|
|
606
|
+
sync: vi.fn().mockResolvedValue(result),
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
const code = await runRunner(["--companies"], deps);
|
|
610
|
+
expect(code).toBe(0);
|
|
611
|
+
const complete = deps.stdout
|
|
612
|
+
.events()
|
|
613
|
+
.find((e) => e.type === "complete") as Extract<RunnerEvent, { type: "complete" }>;
|
|
614
|
+
expect(complete).toEqual({
|
|
615
|
+
type: "complete",
|
|
616
|
+
company: "acme",
|
|
617
|
+
...result,
|
|
618
|
+
});
|
|
619
|
+
});
|
|
620
|
+
|
|
621
|
+
it("passes --on-conflict and --hq-root through to sync()", async () => {
|
|
622
|
+
const syncSpy = vi.fn().mockResolvedValue(defaultSyncResult());
|
|
623
|
+
const deps = makeDeps({
|
|
624
|
+
createVaultClient: () =>
|
|
625
|
+
makeVaultStub({
|
|
626
|
+
memberships: [{ companyUid: "cmp_a" }],
|
|
627
|
+
entityGet: (uid: string) =>
|
|
628
|
+
Promise.resolve({ uid, slug: "acme" } as unknown as EntityInfo),
|
|
629
|
+
}),
|
|
630
|
+
sync: syncSpy,
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
const code = await runRunner(
|
|
634
|
+
[
|
|
635
|
+
"--companies",
|
|
636
|
+
"--on-conflict",
|
|
637
|
+
"overwrite",
|
|
638
|
+
"--hq-root",
|
|
639
|
+
"/tmp/fake-hq",
|
|
640
|
+
],
|
|
641
|
+
deps,
|
|
642
|
+
);
|
|
643
|
+
expect(code).toBe(0);
|
|
644
|
+
expect(syncSpy).toHaveBeenCalledTimes(1);
|
|
645
|
+
const opts = syncSpy.mock.calls[0][0] as SyncOptions;
|
|
646
|
+
expect(opts.onConflict).toBe("overwrite");
|
|
647
|
+
expect(opts.hqRoot).toBe("/tmp/fake-hq");
|
|
648
|
+
});
|
|
649
|
+
|
|
650
|
+
it("continues the fanout when one company's sync throws", async () => {
|
|
651
|
+
const slugs: Record<string, string> = { cmp_a: "acme", cmp_b: "beta" };
|
|
652
|
+
const deps = makeDeps({
|
|
653
|
+
createVaultClient: () =>
|
|
654
|
+
makeVaultStub({
|
|
655
|
+
memberships: [{ companyUid: "cmp_a" }, { companyUid: "cmp_b" }],
|
|
656
|
+
entityGet: (uid: string) =>
|
|
657
|
+
Promise.resolve({ uid, slug: slugs[uid] ?? uid } as unknown as EntityInfo),
|
|
658
|
+
}),
|
|
659
|
+
sync: vi
|
|
660
|
+
.fn<(opts: SyncOptions) => Promise<SyncResult>>()
|
|
661
|
+
.mockImplementationOnce(async () => {
|
|
662
|
+
throw new Error("acme blew up");
|
|
663
|
+
})
|
|
664
|
+
.mockImplementationOnce(async () =>
|
|
665
|
+
defaultSyncResult({ filesDownloaded: 1, bytesDownloaded: 500 }),
|
|
666
|
+
),
|
|
667
|
+
});
|
|
668
|
+
|
|
669
|
+
const code = await runRunner(["--companies"], deps);
|
|
670
|
+
expect(code).toBe(0); // whole fanout still returns 0
|
|
671
|
+
|
|
672
|
+
const events = deps.stdout.events();
|
|
673
|
+
// Error event for acme (company-level) with path sentinel "(company)"
|
|
674
|
+
const companyErr = events.find(
|
|
675
|
+
(e): e is Extract<RunnerEvent, { type: "error" }> =>
|
|
676
|
+
e.type === "error" && e.company === "acme",
|
|
677
|
+
);
|
|
678
|
+
expect(companyErr).toMatchObject({
|
|
679
|
+
type: "error",
|
|
680
|
+
company: "acme",
|
|
681
|
+
path: "(company)",
|
|
682
|
+
message: "acme blew up",
|
|
683
|
+
});
|
|
684
|
+
// But beta still completed
|
|
685
|
+
const betaComplete = events.find(
|
|
686
|
+
(e): e is Extract<RunnerEvent, { type: "complete" }> =>
|
|
687
|
+
e.type === "complete" && e.company === "beta",
|
|
688
|
+
);
|
|
689
|
+
expect(betaComplete).toBeDefined();
|
|
690
|
+
expect(betaComplete?.filesDownloaded).toBe(1);
|
|
691
|
+
});
|
|
692
|
+
});
|
|
693
|
+
|
|
694
|
+
// ---------------------------------------------------------------------------
|
|
695
|
+
// all-complete aggregate
|
|
696
|
+
// ---------------------------------------------------------------------------
|
|
697
|
+
|
|
698
|
+
describe("all-complete aggregate", () => {
|
|
699
|
+
it("sums filesDownloaded and bytesDownloaded across all companies", async () => {
|
|
700
|
+
const slugs: Record<string, string> = { cmp_a: "acme", cmp_b: "beta" };
|
|
701
|
+
const deps = makeDeps({
|
|
702
|
+
createVaultClient: () =>
|
|
703
|
+
makeVaultStub({
|
|
704
|
+
memberships: [{ companyUid: "cmp_a" }, { companyUid: "cmp_b" }],
|
|
705
|
+
entityGet: (uid: string) =>
|
|
706
|
+
Promise.resolve({ uid, slug: slugs[uid] ?? uid } as unknown as EntityInfo),
|
|
707
|
+
}),
|
|
708
|
+
sync: vi
|
|
709
|
+
.fn<(opts: SyncOptions) => Promise<SyncResult>>()
|
|
710
|
+
.mockResolvedValueOnce(
|
|
711
|
+
defaultSyncResult({ filesDownloaded: 3, bytesDownloaded: 100 }),
|
|
712
|
+
)
|
|
713
|
+
.mockResolvedValueOnce(
|
|
714
|
+
defaultSyncResult({ filesDownloaded: 4, bytesDownloaded: 250 }),
|
|
715
|
+
),
|
|
716
|
+
});
|
|
717
|
+
|
|
718
|
+
const code = await runRunner(["--companies"], deps);
|
|
719
|
+
expect(code).toBe(0);
|
|
720
|
+
const all = deps.stdout
|
|
721
|
+
.events()
|
|
722
|
+
.find((e) => e.type === "all-complete") as Extract<RunnerEvent, { type: "all-complete" }>;
|
|
723
|
+
expect(all).toEqual({
|
|
724
|
+
type: "all-complete",
|
|
725
|
+
companiesAttempted: 2,
|
|
726
|
+
filesDownloaded: 7,
|
|
727
|
+
bytesDownloaded: 350,
|
|
728
|
+
errors: [],
|
|
729
|
+
});
|
|
730
|
+
});
|
|
731
|
+
|
|
732
|
+
it("collects company-level errors into the all-complete errors array", async () => {
|
|
733
|
+
const slugs: Record<string, string> = { cmp_a: "acme", cmp_b: "beta" };
|
|
734
|
+
const deps = makeDeps({
|
|
735
|
+
createVaultClient: () =>
|
|
736
|
+
makeVaultStub({
|
|
737
|
+
memberships: [{ companyUid: "cmp_a" }, { companyUid: "cmp_b" }],
|
|
738
|
+
entityGet: (uid: string) =>
|
|
739
|
+
Promise.resolve({ uid, slug: slugs[uid] ?? uid } as unknown as EntityInfo),
|
|
740
|
+
}),
|
|
741
|
+
sync: vi
|
|
742
|
+
.fn<(opts: SyncOptions) => Promise<SyncResult>>()
|
|
743
|
+
.mockRejectedValueOnce(new Error("acme failed"))
|
|
744
|
+
.mockResolvedValueOnce(defaultSyncResult()),
|
|
745
|
+
});
|
|
746
|
+
|
|
747
|
+
const code = await runRunner(["--companies"], deps);
|
|
748
|
+
expect(code).toBe(0);
|
|
749
|
+
const all = deps.stdout
|
|
750
|
+
.events()
|
|
751
|
+
.find((e) => e.type === "all-complete") as Extract<RunnerEvent, { type: "all-complete" }>;
|
|
752
|
+
expect(all.companiesAttempted).toBe(2);
|
|
753
|
+
expect(all.errors).toEqual([
|
|
754
|
+
{ company: "acme", message: "acme failed" },
|
|
755
|
+
]);
|
|
756
|
+
});
|
|
757
|
+
});
|
|
758
|
+
|
|
759
|
+
// ---------------------------------------------------------------------------
|
|
760
|
+
// ndjson stream shape (belt-and-suspenders)
|
|
761
|
+
// ---------------------------------------------------------------------------
|
|
762
|
+
|
|
763
|
+
describe("ndjson stream shape", () => {
|
|
764
|
+
it("emits one JSON object per line, terminated by newline", async () => {
|
|
765
|
+
const deps = makeDeps({
|
|
766
|
+
createVaultClient: () =>
|
|
767
|
+
makeVaultStub({
|
|
768
|
+
memberships: [{ companyUid: "cmp_a" }],
|
|
769
|
+
entityGet: (uid: string) =>
|
|
770
|
+
Promise.resolve({ uid, slug: "acme" } as unknown as EntityInfo),
|
|
771
|
+
}),
|
|
772
|
+
sync: vi.fn().mockImplementation(async (opts: SyncOptions) => {
|
|
773
|
+
opts.onEvent?.({ type: "progress", path: "x.md", bytes: 1 });
|
|
774
|
+
return defaultSyncResult({ filesDownloaded: 1, bytesDownloaded: 1 });
|
|
775
|
+
}),
|
|
776
|
+
});
|
|
777
|
+
|
|
778
|
+
await runRunner(["--companies"], deps);
|
|
779
|
+
const raw = deps.stdout.raw();
|
|
780
|
+
expect(raw.endsWith("\n")).toBe(true);
|
|
781
|
+
// Every line must parse as JSON
|
|
782
|
+
const lines = raw.split("\n").filter((l) => l.length > 0);
|
|
783
|
+
for (const line of lines) {
|
|
784
|
+
expect(() => JSON.parse(line)).not.toThrow();
|
|
785
|
+
}
|
|
786
|
+
// Expected shape: fanout-plan, progress, complete, all-complete
|
|
787
|
+
expect(lines).toHaveLength(4);
|
|
788
|
+
const types = lines.map((l) => (JSON.parse(l) as RunnerEvent).type);
|
|
789
|
+
expect(types).toEqual([
|
|
790
|
+
"fanout-plan",
|
|
791
|
+
"progress",
|
|
792
|
+
"complete",
|
|
793
|
+
"all-complete",
|
|
794
|
+
]);
|
|
795
|
+
});
|
|
796
|
+
});
|
|
797
|
+
|
|
798
|
+
// ---------------------------------------------------------------------------
|
|
799
|
+
// Re-initialize for each test (mock state hygiene)
|
|
800
|
+
// ---------------------------------------------------------------------------
|
|
801
|
+
|
|
802
|
+
beforeEach(() => {
|
|
803
|
+
vi.clearAllMocks();
|
|
804
|
+
});
|