@indigoai-us/hq-cloud 6.14.17 → 6.14.18
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/active-company.d.ts +43 -0
- package/dist/active-company.d.ts.map +1 -0
- package/dist/active-company.js +132 -0
- package/dist/active-company.js.map +1 -0
- package/dist/active-company.test.d.ts +2 -0
- package/dist/active-company.test.d.ts.map +1 -0
- package/dist/active-company.test.js +149 -0
- package/dist/active-company.test.js.map +1 -0
- package/dist/bin/sync-runner-planning.d.ts +27 -1
- package/dist/bin/sync-runner-planning.d.ts.map +1 -1
- package/dist/bin/sync-runner-planning.js +33 -4
- package/dist/bin/sync-runner-planning.js.map +1 -1
- package/dist/bin/sync-runner-planning.test.d.ts +2 -0
- package/dist/bin/sync-runner-planning.test.d.ts.map +1 -0
- package/dist/bin/sync-runner-planning.test.js +115 -0
- package/dist/bin/sync-runner-planning.test.js.map +1 -0
- package/dist/bin/sync-runner.d.ts +22 -7
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +92 -16
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +357 -5
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/manifest-reconcile.d.ts +118 -5
- package/dist/manifest-reconcile.d.ts.map +1 -1
- package/dist/manifest-reconcile.js +319 -62
- package/dist/manifest-reconcile.js.map +1 -1
- package/dist/manifest-reconcile.test.js +824 -2
- package/dist/manifest-reconcile.test.js.map +1 -1
- package/package.json +1 -1
- package/pnpm-workspace.yaml +1 -1
- package/src/active-company.test.ts +188 -0
- package/src/active-company.ts +168 -0
- package/src/bin/sync-runner-planning.test.ts +131 -0
- package/src/bin/sync-runner-planning.ts +60 -7
- package/src/bin/sync-runner.test.ts +430 -10
- package/src/bin/sync-runner.ts +129 -23
- package/src/manifest-reconcile.test.ts +1019 -3
- package/src/manifest-reconcile.ts +424 -66
- package/test/joiner-manifest-reconcile.integration.test.ts +283 -0
|
@@ -22,6 +22,8 @@ import { PARTIAL_SYNC_EXIT } from "../lib/exit-codes.js";
|
|
|
22
22
|
import { isCoveredByAny } from "../prefix-coalesce.js";
|
|
23
23
|
import { VaultAuthError, VaultNotFoundError } from "../vault-client.js";
|
|
24
24
|
import { CognitoRefreshError } from "../cognito-auth.js";
|
|
25
|
+
import { reconcileCompanyManifest, } from "../manifest-reconcile.js";
|
|
26
|
+
import yaml from "js-yaml";
|
|
25
27
|
// ---------------------------------------------------------------------------
|
|
26
28
|
// Hermetic journal state — the runner now calls migratePersonalVaultJournal()
|
|
27
29
|
// on every --companies fanout (one-time seed of the reserved vault journal
|
|
@@ -153,7 +155,9 @@ function makeDeps(overrides = {}) {
|
|
|
153
155
|
embedded: false,
|
|
154
156
|
pendingDirty: false,
|
|
155
157
|
})),
|
|
156
|
-
reconcileManifest: vi
|
|
158
|
+
reconcileManifest: vi
|
|
159
|
+
.fn()
|
|
160
|
+
.mockResolvedValue({ written: false, added: [], updated: [], skipped: [] }),
|
|
157
161
|
...overrides,
|
|
158
162
|
stdout,
|
|
159
163
|
stderr,
|
|
@@ -196,7 +200,9 @@ describe("argv parsing", () => {
|
|
|
196
200
|
const code = await runRunner(["--companies", "--json"], deps);
|
|
197
201
|
expect(code).toBe(0);
|
|
198
202
|
// Empty memberships → setup-needed, not a parse error
|
|
199
|
-
expect(deps.stdout.events()).toEqual([
|
|
203
|
+
expect(deps.stdout.events()).toEqual([
|
|
204
|
+
{ type: "setup-needed", reason: "no-memberships" },
|
|
205
|
+
]);
|
|
200
206
|
});
|
|
201
207
|
});
|
|
202
208
|
// ---------------------------------------------------------------------------
|
|
@@ -499,7 +505,67 @@ describe("claim-dance", () => {
|
|
|
499
505
|
expect(ensureSpy).not.toHaveBeenCalled();
|
|
500
506
|
expect(claimSpy).not.toHaveBeenCalled();
|
|
501
507
|
// No memberships, no invites — truly empty → setup-needed is correct here.
|
|
502
|
-
expect(deps.stdout.events()).toEqual([
|
|
508
|
+
expect(deps.stdout.events()).toEqual([
|
|
509
|
+
{ type: "setup-needed", reason: "no-memberships" },
|
|
510
|
+
]);
|
|
511
|
+
});
|
|
512
|
+
// The michelle@boringecom.com shape: an invite exists, the claim attempt does
|
|
513
|
+
// not convert it into a membership, and the user is told they are solo. The
|
|
514
|
+
// count is what turns "you have no team" into "you have an invite to accept".
|
|
515
|
+
it("US-004: carries pendingInviteCount when invites did not become memberships", async () => {
|
|
516
|
+
const deps = makeDeps({
|
|
517
|
+
createVaultClient: () => makeVaultStub({
|
|
518
|
+
pendingInvites: [{ uid: "inv_1" }, { uid: "inv_2" }],
|
|
519
|
+
ensurePerson: vi
|
|
520
|
+
.fn()
|
|
521
|
+
.mockResolvedValue({ uid: "ent_person" }),
|
|
522
|
+
claim: vi
|
|
523
|
+
.fn()
|
|
524
|
+
.mockResolvedValue(undefined),
|
|
525
|
+
}),
|
|
526
|
+
getIdTokenClaims: () => claims,
|
|
527
|
+
});
|
|
528
|
+
expect(await runRunner(["--companies"], deps)).toBe(0);
|
|
529
|
+
expect(deps.stdout.events()).toEqual([
|
|
530
|
+
{ type: "setup-needed", reason: "no-memberships", pendingInviteCount: 2 },
|
|
531
|
+
]);
|
|
532
|
+
});
|
|
533
|
+
it("US-004: omits pendingInviteCount entirely when there were no invites", async () => {
|
|
534
|
+
const deps = makeDeps({
|
|
535
|
+
createVaultClient: () => makeVaultStub({ pendingInvites: [] }),
|
|
536
|
+
getIdTokenClaims: () => claims,
|
|
537
|
+
});
|
|
538
|
+
expect(await runRunner(["--companies"], deps)).toBe(0);
|
|
539
|
+
const [event] = deps.stdout.events();
|
|
540
|
+
expect(event).toEqual({ type: "setup-needed", reason: "no-memberships" });
|
|
541
|
+
expect("pendingInviteCount" in event).toBe(false);
|
|
542
|
+
});
|
|
543
|
+
it("US-004: reports claim-dance skipped as a diagnostic, not a bare stderr line", async () => {
|
|
544
|
+
const deps = makeDeps({
|
|
545
|
+
createVaultClient: () => makeVaultStub({
|
|
546
|
+
pendingInvites: [{ uid: "inv_1" }],
|
|
547
|
+
ensurePerson: vi
|
|
548
|
+
.fn()
|
|
549
|
+
.mockRejectedValue(new Error("BatchWriteItem denied")),
|
|
550
|
+
}),
|
|
551
|
+
getIdTokenClaims: () => claims,
|
|
552
|
+
});
|
|
553
|
+
// Non-throwing by contract: the failure never changes the exit code...
|
|
554
|
+
expect(await runRunner(["--companies"], deps)).toBe(0);
|
|
555
|
+
// ...but it is no longer invisible.
|
|
556
|
+
expect(deps.stderr.raw()).toContain("runner.claim_dance.skipped");
|
|
557
|
+
});
|
|
558
|
+
it("US-004: a claim-dance that cannot identify the caller still reports the pending invites", async () => {
|
|
559
|
+
const deps = makeDeps({
|
|
560
|
+
createVaultClient: () => makeVaultStub({ pendingInvites: [{ uid: "inv_1" }] }),
|
|
561
|
+
// No sub/name — the claim cannot proceed, but the invites are still real.
|
|
562
|
+
getIdTokenClaims: () => ({}),
|
|
563
|
+
});
|
|
564
|
+
expect(await runRunner(["--companies"], deps)).toBe(0);
|
|
565
|
+
expect(deps.stdout.events()).toEqual([
|
|
566
|
+
{ type: "setup-needed", reason: "no-memberships", pendingInviteCount: 1 },
|
|
567
|
+
]);
|
|
568
|
+
expect(deps.stderr.raw()).toContain("runner.claim_dance.skipped");
|
|
503
569
|
});
|
|
504
570
|
it("skips claim-dance entirely when no idToken claims are available", async () => {
|
|
505
571
|
const pendingSpy = vi.fn().mockResolvedValue([]);
|
|
@@ -569,7 +635,9 @@ describe("target resolution", () => {
|
|
|
569
635
|
const deps = makeDeps();
|
|
570
636
|
const code = await runRunner(["--companies"], deps);
|
|
571
637
|
expect(code).toBe(0);
|
|
572
|
-
expect(deps.stdout.events()).toEqual([
|
|
638
|
+
expect(deps.stdout.events()).toEqual([
|
|
639
|
+
{ type: "setup-needed", reason: "no-memberships" },
|
|
640
|
+
]);
|
|
573
641
|
// sync should NOT have been called — no targets
|
|
574
642
|
expect(deps.sync).not.toHaveBeenCalled();
|
|
575
643
|
});
|
|
@@ -856,7 +924,11 @@ describe("fanout-plan", () => {
|
|
|
856
924
|
const plan = deps.stdout
|
|
857
925
|
.events()
|
|
858
926
|
.find((e) => e.type === "fanout-plan");
|
|
859
|
-
|
|
927
|
+
// The surviving target now also carries the plan-time entity snapshot the
|
|
928
|
+
// manifest reconciler reads instead of re-fetching (US-002).
|
|
929
|
+
expect(plan.companies).toEqual([
|
|
930
|
+
{ uid: "cmp_live", slug: "live", entityType: "company", entityStatus: "active" },
|
|
931
|
+
]);
|
|
860
932
|
});
|
|
861
933
|
it("reconciles after the personal leg completes", async () => {
|
|
862
934
|
const order = [];
|
|
@@ -887,6 +959,7 @@ describe("fanout-plan", () => {
|
|
|
887
959
|
}),
|
|
888
960
|
reconcileManifest: vi.fn().mockImplementation(async () => {
|
|
889
961
|
order.push("reconcile");
|
|
962
|
+
return { written: false, added: [], updated: [], skipped: [] };
|
|
890
963
|
}),
|
|
891
964
|
});
|
|
892
965
|
expect(await runRunner(["--companies"], deps)).toBe(0);
|
|
@@ -5342,4 +5415,283 @@ describe("US-004 — --personal for an AGENT machine identity (HARD GATE)", () =
|
|
|
5342
5415
|
expect(cmp(HQ_CLOUD_VERSION, FLOOR)).toBeGreaterThanOrEqual(0);
|
|
5343
5416
|
});
|
|
5344
5417
|
});
|
|
5418
|
+
// ---------------------------------------------------------------------------
|
|
5419
|
+
// US-002 — post-fanout manifest reconciliation wiring
|
|
5420
|
+
//
|
|
5421
|
+
// The reconciler itself is unit-tested in manifest-reconcile.test.ts. These
|
|
5422
|
+
// tests own the SEAM: which run modes reach it, which targets cross it, and
|
|
5423
|
+
// what the runner does with the result it hands back.
|
|
5424
|
+
// ---------------------------------------------------------------------------
|
|
5425
|
+
describe("US-002 — manifest reconciliation wiring", () => {
|
|
5426
|
+
const PERSON_ENTITY = {
|
|
5427
|
+
uid: "prs_me",
|
|
5428
|
+
slug: "me",
|
|
5429
|
+
type: "person",
|
|
5430
|
+
status: "active",
|
|
5431
|
+
bucketName: "hq-vault-prs-me",
|
|
5432
|
+
createdAt: "2026-01-01T00:00:00Z",
|
|
5433
|
+
};
|
|
5434
|
+
function noopReconcileResult() {
|
|
5435
|
+
return { written: false, added: [], updated: [], skipped: [] };
|
|
5436
|
+
}
|
|
5437
|
+
/** A vault stub whose companies all resolve to `cmp_x` → slug `x`. */
|
|
5438
|
+
function companiesClient(companyUids) {
|
|
5439
|
+
return makeVaultStub({
|
|
5440
|
+
memberships: companyUids.map((companyUid) => ({ companyUid })),
|
|
5441
|
+
entityGet: (uid) => Promise.resolve({
|
|
5442
|
+
uid,
|
|
5443
|
+
slug: uid.replace(/^cmp_/, ""),
|
|
5444
|
+
name: `${uid} Inc`,
|
|
5445
|
+
type: "company",
|
|
5446
|
+
status: "active",
|
|
5447
|
+
bucketName: `bucket-${uid}`,
|
|
5448
|
+
createdAt: "2026-01-01T00:00:00Z",
|
|
5449
|
+
}),
|
|
5450
|
+
listPersons: () => Promise.resolve([PERSON_ENTITY]),
|
|
5451
|
+
});
|
|
5452
|
+
}
|
|
5453
|
+
function reconcileArgs(reconcile) {
|
|
5454
|
+
return reconcile.mock.calls[0][0];
|
|
5455
|
+
}
|
|
5456
|
+
/** Diagnostics ride stderr as ndjson; pick out the manifest ones. */
|
|
5457
|
+
function manifestDiagnostics(stderr) {
|
|
5458
|
+
return stderr
|
|
5459
|
+
.lines()
|
|
5460
|
+
.flatMap((line) => {
|
|
5461
|
+
try {
|
|
5462
|
+
return [JSON.parse(line)];
|
|
5463
|
+
}
|
|
5464
|
+
catch {
|
|
5465
|
+
return [];
|
|
5466
|
+
}
|
|
5467
|
+
})
|
|
5468
|
+
.filter((entry) => entry.component === "manifest-reconcile");
|
|
5469
|
+
}
|
|
5470
|
+
it("AC2: an errored company is not eligible — only the completing company is", async () => {
|
|
5471
|
+
const reconcile = vi.fn().mockResolvedValue(noopReconcileResult());
|
|
5472
|
+
const deps = makeDeps({
|
|
5473
|
+
createVaultClient: () => companiesClient(["cmp_ok", "cmp_bad"]),
|
|
5474
|
+
sync: vi.fn().mockImplementation(async (options) => {
|
|
5475
|
+
if (options.company === "cmp_bad")
|
|
5476
|
+
throw new Error("pull leg exploded");
|
|
5477
|
+
return defaultSyncResult();
|
|
5478
|
+
}),
|
|
5479
|
+
reconcileManifest: reconcile,
|
|
5480
|
+
});
|
|
5481
|
+
await runRunner(["--companies"], deps);
|
|
5482
|
+
expect(reconcile).toHaveBeenCalledTimes(1);
|
|
5483
|
+
const args = reconcileArgs(reconcile);
|
|
5484
|
+
// The completion filter is `completedCompanySlugs`; assert the EFFECTIVE
|
|
5485
|
+
// eligible set, which is what actually reaches the manifest.
|
|
5486
|
+
const eligible = args.targets.filter((target) => args.completedCompanySlugs.has(target.slug));
|
|
5487
|
+
expect(eligible.map((target) => target.slug)).toEqual(["ok"]);
|
|
5488
|
+
expect(args.completedCompanySlugs.has("bad")).toBe(false);
|
|
5489
|
+
});
|
|
5490
|
+
it("AC3: the personal target is never passed to the reconciler", async () => {
|
|
5491
|
+
const reconcile = vi.fn().mockResolvedValue(noopReconcileResult());
|
|
5492
|
+
const deps = makeDeps({
|
|
5493
|
+
createVaultClient: () => companiesClient(["cmp_acme"]),
|
|
5494
|
+
reconcileManifest: reconcile,
|
|
5495
|
+
});
|
|
5496
|
+
expect(await runRunner(["--companies"], deps)).toBe(0);
|
|
5497
|
+
const args = reconcileArgs(reconcile);
|
|
5498
|
+
// The personal leg definitely ran — otherwise this assertion is vacuous.
|
|
5499
|
+
const plan = deps.stdout
|
|
5500
|
+
.events()
|
|
5501
|
+
.find((e) => e.type === "fanout-plan");
|
|
5502
|
+
expect(plan.companies.map((t) => t.slug)).toContain("personal");
|
|
5503
|
+
expect(args.targets.map((target) => target.slug)).toEqual(["acme"]);
|
|
5504
|
+
expect(args.targets.some((target) => target.personalMode === true)).toBe(false);
|
|
5505
|
+
expect(args.targets.some((target) => target.slug === "personal")).toBe(false);
|
|
5506
|
+
});
|
|
5507
|
+
it("AC4: --personal mode does not reconcile", async () => {
|
|
5508
|
+
const reconcile = vi.fn().mockResolvedValue(noopReconcileResult());
|
|
5509
|
+
const deps = makeDeps({
|
|
5510
|
+
createVaultClient: () => makeVaultStub({ listPersons: () => Promise.resolve([PERSON_ENTITY]) }),
|
|
5511
|
+
reconcileManifest: reconcile,
|
|
5512
|
+
});
|
|
5513
|
+
expect(await runRunner(["--personal"], deps)).toBe(0);
|
|
5514
|
+
expect(reconcile).not.toHaveBeenCalled();
|
|
5515
|
+
});
|
|
5516
|
+
it("AC4: single-company mode (--company <slug>) does not reconcile", async () => {
|
|
5517
|
+
const reconcile = vi.fn().mockResolvedValue(noopReconcileResult());
|
|
5518
|
+
const deps = makeDeps({
|
|
5519
|
+
createVaultClient: () => companiesClient(["cmp_acme"]),
|
|
5520
|
+
reconcileManifest: reconcile,
|
|
5521
|
+
});
|
|
5522
|
+
expect(await runRunner(["--company", "cmp_acme"], deps)).toBe(0);
|
|
5523
|
+
expect(reconcile).not.toHaveBeenCalled();
|
|
5524
|
+
});
|
|
5525
|
+
it("AC5: company targets carry the plan-resolved bucketName, so no getEntity is passed", async () => {
|
|
5526
|
+
const reconcile = vi.fn().mockResolvedValue(noopReconcileResult());
|
|
5527
|
+
const deps = makeDeps({
|
|
5528
|
+
createVaultClient: () => companiesClient(["cmp_acme"]),
|
|
5529
|
+
reconcileManifest: reconcile,
|
|
5530
|
+
});
|
|
5531
|
+
expect(await runRunner(["--companies"], deps)).toBe(0);
|
|
5532
|
+
const args = reconcileArgs(reconcile);
|
|
5533
|
+
expect(args.targets[0]).toMatchObject({
|
|
5534
|
+
uid: "cmp_acme",
|
|
5535
|
+
slug: "acme",
|
|
5536
|
+
name: "cmp_acme Inc",
|
|
5537
|
+
bucketName: "bucket-cmp_acme",
|
|
5538
|
+
// Liveness evidence travels with the target. Dropping the round trip
|
|
5539
|
+
// must not drop the guarantee it used to provide.
|
|
5540
|
+
entityType: "company",
|
|
5541
|
+
entityStatus: "active",
|
|
5542
|
+
});
|
|
5543
|
+
// Zero extra API calls: the runner hands over no entity resolver at all.
|
|
5544
|
+
expect(args.getEntity).toBeUndefined();
|
|
5545
|
+
});
|
|
5546
|
+
it("END-TO-END: a suspended company completes its sync leg but never reaches the manifest", async () => {
|
|
5547
|
+
// Exercises the REAL reconciler through the runner — no stub — because the
|
|
5548
|
+
// defect this guards was invisible to every stubbed assertion.
|
|
5549
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "hq-runner-manifest-"));
|
|
5550
|
+
try {
|
|
5551
|
+
fs.mkdirSync(path.join(root, "companies", "live"), { recursive: true });
|
|
5552
|
+
fs.mkdirSync(path.join(root, "companies", "susp"), { recursive: true });
|
|
5553
|
+
const manifestPath = path.join(root, "companies", "manifest.yaml");
|
|
5554
|
+
fs.writeFileSync(manifestPath, "companies: {}\n");
|
|
5555
|
+
const deps = makeDeps({
|
|
5556
|
+
createVaultClient: () => makeVaultStub({
|
|
5557
|
+
memberships: [{ companyUid: "cmp_live" }, { companyUid: "cmp_susp" }],
|
|
5558
|
+
entityGet: (uid) => Promise.resolve({
|
|
5559
|
+
uid,
|
|
5560
|
+
slug: uid === "cmp_live" ? "live" : "susp",
|
|
5561
|
+
type: "company",
|
|
5562
|
+
// The suspended company is still a perfectly syncable target.
|
|
5563
|
+
status: uid === "cmp_live" ? "active" : "suspended",
|
|
5564
|
+
bucketName: `bucket-${uid}`,
|
|
5565
|
+
createdAt: "2026-01-01T00:00:00Z",
|
|
5566
|
+
}),
|
|
5567
|
+
listPersons: () => Promise.resolve([PERSON_ENTITY]),
|
|
5568
|
+
}),
|
|
5569
|
+
// The REAL reconciler, not the makeDeps stub — that stub is exactly
|
|
5570
|
+
// what let this defect hide from every other assertion here.
|
|
5571
|
+
reconcileManifest: reconcileCompanyManifest,
|
|
5572
|
+
});
|
|
5573
|
+
expect(await runRunner(["--companies", "--hq-root", root], deps)).toBe(0);
|
|
5574
|
+
// Both companies synced (the guard must not have leaked into sync).
|
|
5575
|
+
const syncedCompanies = deps.sync.mock.calls
|
|
5576
|
+
.map((call) => call[0].company)
|
|
5577
|
+
.filter(Boolean);
|
|
5578
|
+
expect(syncedCompanies).toContain("cmp_live");
|
|
5579
|
+
expect(syncedCompanies).toContain("cmp_susp");
|
|
5580
|
+
// ...but only the active one earned a manifest entry.
|
|
5581
|
+
const written = yaml.load(fs.readFileSync(manifestPath, "utf8"));
|
|
5582
|
+
expect(Object.keys(written.companies ?? {})).toEqual(["live"]);
|
|
5583
|
+
}
|
|
5584
|
+
finally {
|
|
5585
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
5586
|
+
}
|
|
5587
|
+
});
|
|
5588
|
+
it("US-003: seeds activeCompany from the completed company set", async () => {
|
|
5589
|
+
const seed = vi.fn().mockReturnValue({ written: true, activeCompany: "acme" });
|
|
5590
|
+
const deps = makeDeps({
|
|
5591
|
+
createVaultClient: () => companiesClient(["cmp_acme"]),
|
|
5592
|
+
seedActiveCompany: seed,
|
|
5593
|
+
});
|
|
5594
|
+
expect(await runRunner(["--companies"], deps)).toBe(0);
|
|
5595
|
+
expect(seed).toHaveBeenCalledTimes(1);
|
|
5596
|
+
expect(seed.mock.calls[0][0]).toMatchObject({ companySlugs: ["acme"] });
|
|
5597
|
+
expect(deps.stderr.raw()).toContain("active-company-seeded");
|
|
5598
|
+
});
|
|
5599
|
+
it("US-003: the personal target is never offered as an activeCompany candidate", async () => {
|
|
5600
|
+
const seed = vi.fn().mockReturnValue({ written: false, skippedReason: "already-set" });
|
|
5601
|
+
const deps = makeDeps({
|
|
5602
|
+
createVaultClient: () => companiesClient(["cmp_acme"]),
|
|
5603
|
+
seedActiveCompany: seed,
|
|
5604
|
+
});
|
|
5605
|
+
expect(await runRunner(["--companies"], deps)).toBe(0);
|
|
5606
|
+
expect(seed.mock.calls[0][0].companySlugs).not.toContain("personal");
|
|
5607
|
+
});
|
|
5608
|
+
// The two writes are independent repairs. A joiner whose manifest write fails
|
|
5609
|
+
// should still get routed, and vice versa.
|
|
5610
|
+
it("US-003: a failing manifest reconcile does not prevent activeCompany seeding", async () => {
|
|
5611
|
+
const seed = vi.fn().mockReturnValue({ written: true, activeCompany: "acme" });
|
|
5612
|
+
const deps = makeDeps({
|
|
5613
|
+
createVaultClient: () => companiesClient(["cmp_acme"]),
|
|
5614
|
+
reconcileManifest: vi.fn().mockRejectedValue(new Error("manifest write blew up")),
|
|
5615
|
+
seedActiveCompany: seed,
|
|
5616
|
+
});
|
|
5617
|
+
expect(await runRunner(["--companies"], deps)).toBe(0);
|
|
5618
|
+
expect(deps.stderr.raw()).toContain("runner.manifest_reconcile.failed");
|
|
5619
|
+
expect(seed).toHaveBeenCalledTimes(1);
|
|
5620
|
+
});
|
|
5621
|
+
it("US-003: a throwing seeder still emits all-complete and exits 0", async () => {
|
|
5622
|
+
const deps = makeDeps({
|
|
5623
|
+
createVaultClient: () => companiesClient(["cmp_acme"]),
|
|
5624
|
+
seedActiveCompany: vi.fn().mockImplementation(() => {
|
|
5625
|
+
throw new Error("config write blew up");
|
|
5626
|
+
}),
|
|
5627
|
+
});
|
|
5628
|
+
expect(await runRunner(["--companies"], deps)).toBe(0);
|
|
5629
|
+
expect(deps.stdout.events().some((e) => e.type === "all-complete")).toBe(true);
|
|
5630
|
+
expect(deps.stderr.raw()).toContain("runner.active_company.failed");
|
|
5631
|
+
});
|
|
5632
|
+
it("US-003: a no-op seeding is silent", async () => {
|
|
5633
|
+
const deps = makeDeps({
|
|
5634
|
+
createVaultClient: () => companiesClient(["cmp_acme"]),
|
|
5635
|
+
seedActiveCompany: vi
|
|
5636
|
+
.fn()
|
|
5637
|
+
.mockReturnValue({ written: false, skippedReason: "already-set" }),
|
|
5638
|
+
});
|
|
5639
|
+
expect(await runRunner(["--companies"], deps)).toBe(0);
|
|
5640
|
+
expect(deps.stderr.raw()).not.toContain("active-company-seeded");
|
|
5641
|
+
});
|
|
5642
|
+
it("US-003: single-company mode does not seed activeCompany", async () => {
|
|
5643
|
+
const seed = vi.fn();
|
|
5644
|
+
const deps = makeDeps({
|
|
5645
|
+
createVaultClient: () => companiesClient(["cmp_acme"]),
|
|
5646
|
+
seedActiveCompany: seed,
|
|
5647
|
+
});
|
|
5648
|
+
await runRunner(["--company", "acme"], deps);
|
|
5649
|
+
expect(seed).not.toHaveBeenCalled();
|
|
5650
|
+
});
|
|
5651
|
+
it("AC6: a throwing reconciler still emits all-complete and exits 0", async () => {
|
|
5652
|
+
const deps = makeDeps({
|
|
5653
|
+
createVaultClient: () => companiesClient(["cmp_acme"]),
|
|
5654
|
+
reconcileManifest: vi
|
|
5655
|
+
.fn()
|
|
5656
|
+
.mockRejectedValue(new Error("manifest write blew up")),
|
|
5657
|
+
});
|
|
5658
|
+
expect(await runRunner(["--companies"], deps)).toBe(0);
|
|
5659
|
+
expect(deps.stdout.events().some((e) => e.type === "all-complete")).toBe(true);
|
|
5660
|
+
expect(deps.stderr.raw()).toContain("runner.manifest_reconcile.failed");
|
|
5661
|
+
});
|
|
5662
|
+
it("AC7: a write emits a manifest-reconciled diagnostic naming the slugs", async () => {
|
|
5663
|
+
const deps = makeDeps({
|
|
5664
|
+
createVaultClient: () => companiesClient(["cmp_acme"]),
|
|
5665
|
+
reconcileManifest: vi.fn().mockResolvedValue({
|
|
5666
|
+
written: true,
|
|
5667
|
+
added: ["acme"],
|
|
5668
|
+
updated: ["beta"],
|
|
5669
|
+
skipped: [],
|
|
5670
|
+
}),
|
|
5671
|
+
});
|
|
5672
|
+
expect(await runRunner(["--companies"], deps)).toBe(0);
|
|
5673
|
+
const diagnostics = manifestDiagnostics(deps.stderr);
|
|
5674
|
+
expect(diagnostics).toHaveLength(1);
|
|
5675
|
+
expect(diagnostics[0]).toMatchObject({
|
|
5676
|
+
component: "manifest-reconcile",
|
|
5677
|
+
event: "manifest-reconciled",
|
|
5678
|
+
added: ["acme"],
|
|
5679
|
+
updated: ["beta"],
|
|
5680
|
+
});
|
|
5681
|
+
});
|
|
5682
|
+
it("AC7: a no-op reconciliation is completely silent (steady-state sync)", async () => {
|
|
5683
|
+
const deps = makeDeps({
|
|
5684
|
+
createVaultClient: () => companiesClient(["cmp_acme"]),
|
|
5685
|
+
reconcileManifest: vi.fn().mockResolvedValue({
|
|
5686
|
+
written: false,
|
|
5687
|
+
added: [],
|
|
5688
|
+
updated: [],
|
|
5689
|
+
skipped: ["weird-slug"],
|
|
5690
|
+
}),
|
|
5691
|
+
});
|
|
5692
|
+
expect(await runRunner(["--companies"], deps)).toBe(0);
|
|
5693
|
+
expect(manifestDiagnostics(deps.stderr)).toEqual([]);
|
|
5694
|
+
expect(deps.stderr.raw()).not.toContain("manifest-reconciled");
|
|
5695
|
+
});
|
|
5696
|
+
});
|
|
5345
5697
|
//# sourceMappingURL=sync-runner.test.js.map
|