@indigoai-us/hq-cloud 5.24.0 → 5.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin/sync-runner.d.ts +151 -17
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +280 -18
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +429 -15
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/share.d.ts +9 -0
- package/dist/cli/share.d.ts.map +1 -1
- package/dist/cli/share.js +54 -1
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/share.test.js +6 -3
- package/dist/cli/share.test.js.map +1 -1
- package/dist/cli/sync.d.ts +21 -0
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js.map +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -1
- package/dist/personal-vault-exclusions.d.ts +128 -0
- package/dist/personal-vault-exclusions.d.ts.map +1 -0
- package/dist/personal-vault-exclusions.js +231 -0
- package/dist/personal-vault-exclusions.js.map +1 -0
- package/dist/personal-vault-exclusions.test.d.ts +22 -0
- package/dist/personal-vault-exclusions.test.d.ts.map +1 -0
- package/dist/personal-vault-exclusions.test.js +198 -0
- package/dist/personal-vault-exclusions.test.js.map +1 -0
- package/dist/sync/index.d.ts +11 -0
- package/dist/sync/index.d.ts.map +1 -0
- package/dist/sync/index.js +9 -0
- package/dist/sync/index.js.map +1 -0
- package/dist/sync/push-event.d.ts +110 -0
- package/dist/sync/push-event.d.ts.map +1 -0
- package/dist/sync/push-event.js +153 -0
- package/dist/sync/push-event.js.map +1 -0
- package/dist/sync/push-event.test.d.ts +15 -0
- package/dist/sync/push-event.test.d.ts.map +1 -0
- package/dist/sync/push-event.test.js +188 -0
- package/dist/sync/push-event.test.js.map +1 -0
- package/dist/sync/push-transport.d.ts +67 -0
- package/dist/sync/push-transport.d.ts.map +1 -0
- package/dist/sync/push-transport.js +66 -0
- package/dist/sync/push-transport.js.map +1 -0
- package/dist/watcher.d.ts +160 -0
- package/dist/watcher.d.ts.map +1 -1
- package/dist/watcher.js +298 -0
- package/dist/watcher.js.map +1 -1
- package/dist/watcher.test.d.ts +2 -0
- package/dist/watcher.test.d.ts.map +1 -0
- package/dist/watcher.test.js +334 -0
- package/dist/watcher.test.js.map +1 -0
- package/package.json +3 -2
- package/src/bin/sync-runner.test.ts +557 -15
- package/src/bin/sync-runner.ts +404 -27
- package/src/cli/share.test.ts +8 -3
- package/src/cli/share.ts +66 -1
- package/src/cli/sync.ts +22 -0
- package/src/index.ts +27 -0
- package/src/personal-vault-exclusions.test.ts +256 -0
- package/src/personal-vault-exclusions.ts +277 -0
- package/src/sync/index.ts +19 -0
- package/src/sync/push-event.test.ts +224 -0
- package/src/sync/push-event.ts +208 -0
- package/src/sync/push-transport.ts +84 -0
- package/src/watcher.test.ts +388 -0
- package/src/watcher.ts +386 -0
|
@@ -11,12 +11,21 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
|
11
11
|
import * as fs from "fs";
|
|
12
12
|
import * as os from "os";
|
|
13
13
|
import * as path from "path";
|
|
14
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
runRunner,
|
|
16
|
+
runRunnerWithLoop,
|
|
17
|
+
resolveDeletePolicy,
|
|
18
|
+
resolveSkipPersonal,
|
|
19
|
+
routeChangeToTarget,
|
|
20
|
+
buildTargetedPushArgv,
|
|
21
|
+
} from "./sync-runner.js";
|
|
15
22
|
import type {
|
|
16
23
|
RunnerEvent,
|
|
17
24
|
RunnerDeps,
|
|
18
25
|
VaultClientSurface,
|
|
26
|
+
WatcherSurface,
|
|
19
27
|
} from "./sync-runner.js";
|
|
28
|
+
import { FakeClock } from "../watcher.js";
|
|
20
29
|
import type { SyncResult, SyncOptions } from "../cli/sync.js";
|
|
21
30
|
import type { ShareResult, ShareOptions } from "../cli/share.js";
|
|
22
31
|
import type {
|
|
@@ -80,6 +89,7 @@ function defaultShareResult(overrides: Partial<ShareResult> = {}): ShareResult {
|
|
|
80
89
|
filesDeleted: 0,
|
|
81
90
|
filesTombstoned: 0,
|
|
82
91
|
filesRefusedStale: 0,
|
|
92
|
+
filesExcludedByPolicy: 0,
|
|
83
93
|
conflictPaths: [],
|
|
84
94
|
aborted: false,
|
|
85
95
|
...overrides,
|
|
@@ -687,7 +697,9 @@ describe("per-company fanout", () => {
|
|
|
687
697
|
const complete = deps.stdout
|
|
688
698
|
.events()
|
|
689
699
|
.find((e) => e.type === "complete") as Extract<RunnerEvent, { type: "complete" }>;
|
|
690
|
-
// Pull-only run: upload counters are 0.
|
|
700
|
+
// Pull-only run: upload counters are 0. Push-side counters added in
|
|
701
|
+
// 5.25 (filesTombstoned/filesRefusedStale/filesExcludedByPolicy) are
|
|
702
|
+
// also 0 because no push leg ran.
|
|
691
703
|
expect(complete).toEqual({
|
|
692
704
|
type: "complete",
|
|
693
705
|
company: "acme",
|
|
@@ -699,6 +711,9 @@ describe("per-company fanout", () => {
|
|
|
699
711
|
aborted: result.aborted,
|
|
700
712
|
filesUploaded: 0,
|
|
701
713
|
bytesUploaded: 0,
|
|
714
|
+
filesTombstoned: 0,
|
|
715
|
+
filesRefusedStale: 0,
|
|
716
|
+
filesExcludedByPolicy: 0,
|
|
702
717
|
newFiles: result.newFiles,
|
|
703
718
|
newFilesCount: result.newFilesCount,
|
|
704
719
|
});
|
|
@@ -1696,6 +1711,483 @@ describe("watch mode argv parsing", () => {
|
|
|
1696
1711
|
expect(code).toBe(1);
|
|
1697
1712
|
expect(deps.stderr.raw()).toContain("--poll-remote-ms requires --watch");
|
|
1698
1713
|
});
|
|
1714
|
+
|
|
1715
|
+
it("accepts --event-push together with --watch (parser-level)", async () => {
|
|
1716
|
+
const deps = makeDeps({
|
|
1717
|
+
createVaultClient: () => makeVaultStub({ memberships: [] }),
|
|
1718
|
+
});
|
|
1719
|
+
// One-shot single pass via runRunner (no loop) — the parser must accept
|
|
1720
|
+
// --event-push as a known flag. No memberships → setup-needed → exit 0.
|
|
1721
|
+
const code = await runRunner(
|
|
1722
|
+
["--companies", "--watch", "--event-push"],
|
|
1723
|
+
deps,
|
|
1724
|
+
);
|
|
1725
|
+
expect(deps.stderr.raw()).not.toContain("Unknown argument");
|
|
1726
|
+
expect(code).toBe(0);
|
|
1727
|
+
});
|
|
1728
|
+
|
|
1729
|
+
it("rejects --event-push without --watch (the flag has no meaning otherwise)", async () => {
|
|
1730
|
+
const deps = makeDeps();
|
|
1731
|
+
const code = await runRunner(["--companies", "--event-push"], deps);
|
|
1732
|
+
expect(code).toBe(1);
|
|
1733
|
+
expect(deps.stderr.raw()).toContain("--event-push requires --watch");
|
|
1734
|
+
});
|
|
1735
|
+
|
|
1736
|
+
it("is OFF by default — plain --watch run does not require/accept event flags implicitly", async () => {
|
|
1737
|
+
const deps = makeDeps({
|
|
1738
|
+
createVaultClient: () => makeVaultStub({ memberships: [] }),
|
|
1739
|
+
});
|
|
1740
|
+
const code = await runRunner(["--companies", "--watch"], deps);
|
|
1741
|
+
expect(deps.stderr.raw()).not.toContain("Unknown argument");
|
|
1742
|
+
expect(code).toBe(0);
|
|
1743
|
+
});
|
|
1744
|
+
});
|
|
1745
|
+
|
|
1746
|
+
// ---------------------------------------------------------------------------
|
|
1747
|
+
// US-003 — event-driven push routing helpers (pure)
|
|
1748
|
+
// ---------------------------------------------------------------------------
|
|
1749
|
+
|
|
1750
|
+
describe("routeChangeToTarget", () => {
|
|
1751
|
+
it("routes companies/<slug>/... to a targeted company push", () => {
|
|
1752
|
+
expect(routeChangeToTarget("companies/indigo/board.json")).toEqual({
|
|
1753
|
+
kind: "company",
|
|
1754
|
+
slug: "indigo",
|
|
1755
|
+
});
|
|
1756
|
+
expect(
|
|
1757
|
+
routeChangeToTarget("companies/acme/projects/x/prd.json"),
|
|
1758
|
+
).toEqual({ kind: "company", slug: "acme" });
|
|
1759
|
+
});
|
|
1760
|
+
|
|
1761
|
+
it("routes non-company top-levels to the personal target", () => {
|
|
1762
|
+
expect(routeChangeToTarget("personal/notes.md")).toEqual({
|
|
1763
|
+
kind: "personal",
|
|
1764
|
+
});
|
|
1765
|
+
expect(routeChangeToTarget("core/policies/x.md")).toEqual({
|
|
1766
|
+
kind: "personal",
|
|
1767
|
+
});
|
|
1768
|
+
expect(routeChangeToTarget("README.md")).toEqual({ kind: "personal" });
|
|
1769
|
+
});
|
|
1770
|
+
|
|
1771
|
+
it("normalizes OS path separators and ./ prefixes", () => {
|
|
1772
|
+
expect(
|
|
1773
|
+
routeChangeToTarget(["companies", "indigo", "f.json"].join(path.sep)),
|
|
1774
|
+
).toEqual({ kind: "company", slug: "indigo" });
|
|
1775
|
+
expect(routeChangeToTarget("./companies/indigo/f.json")).toEqual({
|
|
1776
|
+
kind: "company",
|
|
1777
|
+
slug: "indigo",
|
|
1778
|
+
});
|
|
1779
|
+
});
|
|
1780
|
+
|
|
1781
|
+
it("returns null for unattributable paths (defensive)", () => {
|
|
1782
|
+
expect(routeChangeToTarget("")).toBeNull();
|
|
1783
|
+
expect(routeChangeToTarget("..")).toBeNull();
|
|
1784
|
+
expect(routeChangeToTarget("../escape")).toBeNull();
|
|
1785
|
+
// companies top-level with no slug segment is unattributable — a real
|
|
1786
|
+
// edit always lands at companies/<slug>/..., never at bare "companies".
|
|
1787
|
+
expect(routeChangeToTarget("companies")).toBeNull();
|
|
1788
|
+
expect(routeChangeToTarget("companies/")).toBeNull();
|
|
1789
|
+
});
|
|
1790
|
+
});
|
|
1791
|
+
|
|
1792
|
+
describe("buildTargetedPushArgv", () => {
|
|
1793
|
+
it("company route → --company <slug> --direction push", () => {
|
|
1794
|
+
expect(
|
|
1795
|
+
buildTargetedPushArgv({ kind: "company", slug: "indigo" }, [
|
|
1796
|
+
"--companies",
|
|
1797
|
+
"--hq-root",
|
|
1798
|
+
"/tmp/hq",
|
|
1799
|
+
"--on-conflict",
|
|
1800
|
+
"keep",
|
|
1801
|
+
]),
|
|
1802
|
+
).toEqual([
|
|
1803
|
+
"--company",
|
|
1804
|
+
"indigo",
|
|
1805
|
+
"--direction",
|
|
1806
|
+
"push",
|
|
1807
|
+
"--hq-root",
|
|
1808
|
+
"/tmp/hq",
|
|
1809
|
+
"--on-conflict",
|
|
1810
|
+
"keep",
|
|
1811
|
+
]);
|
|
1812
|
+
});
|
|
1813
|
+
|
|
1814
|
+
it("personal route → --companies --direction push (inherits root/conflict)", () => {
|
|
1815
|
+
expect(
|
|
1816
|
+
buildTargetedPushArgv({ kind: "personal" }, [
|
|
1817
|
+
"--companies",
|
|
1818
|
+
"--hq-root",
|
|
1819
|
+
"/tmp/hq",
|
|
1820
|
+
]),
|
|
1821
|
+
).toEqual(["--companies", "--direction", "push", "--hq-root", "/tmp/hq"]);
|
|
1822
|
+
});
|
|
1823
|
+
|
|
1824
|
+
it("carries no flags when the base argv has none", () => {
|
|
1825
|
+
expect(buildTargetedPushArgv({ kind: "personal" }, ["--companies"])).toEqual(
|
|
1826
|
+
["--companies", "--direction", "push"],
|
|
1827
|
+
);
|
|
1828
|
+
});
|
|
1829
|
+
});
|
|
1830
|
+
|
|
1831
|
+
// ---------------------------------------------------------------------------
|
|
1832
|
+
// US-003 — runRunnerWithLoop event-push wiring (deterministic, no real timers)
|
|
1833
|
+
// ---------------------------------------------------------------------------
|
|
1834
|
+
//
|
|
1835
|
+
// These exercise the full Phase 1 wiring through injectable seams:
|
|
1836
|
+
// - runPass: a spy standing in for runRunner (no real S3/auth)
|
|
1837
|
+
// - clock: FakeClock to advance the debounce window deterministically
|
|
1838
|
+
// - createWatcher: a controllable stub satisfying WatcherSurface
|
|
1839
|
+
// - sleep / onShutdownSignal: drive the poll loop + shutdown without real
|
|
1840
|
+
// timers or process signals
|
|
1841
|
+
//
|
|
1842
|
+
// The loop is an infinite poll; we drive ONE pass then trigger shutdown so the
|
|
1843
|
+
// promise resolves. A test helper wires everything and returns the controls.
|
|
1844
|
+
|
|
1845
|
+
interface WatcherStub extends WatcherSurface {
|
|
1846
|
+
emit(changedRelPath?: string): void;
|
|
1847
|
+
started: boolean;
|
|
1848
|
+
disposed: boolean;
|
|
1849
|
+
}
|
|
1850
|
+
|
|
1851
|
+
function makeWatcherStub(): WatcherStub {
|
|
1852
|
+
const listeners = new Set<(p?: string) => void>();
|
|
1853
|
+
const stub: WatcherStub = {
|
|
1854
|
+
started: false,
|
|
1855
|
+
disposed: false,
|
|
1856
|
+
onChange(listener) {
|
|
1857
|
+
listeners.add(listener);
|
|
1858
|
+
return () => listeners.delete(listener);
|
|
1859
|
+
},
|
|
1860
|
+
start() {
|
|
1861
|
+
this.started = true;
|
|
1862
|
+
},
|
|
1863
|
+
stop() {
|
|
1864
|
+
/* no-op for the stub */
|
|
1865
|
+
},
|
|
1866
|
+
dispose() {
|
|
1867
|
+
this.disposed = true;
|
|
1868
|
+
listeners.clear();
|
|
1869
|
+
},
|
|
1870
|
+
emit(changedRelPath?: string) {
|
|
1871
|
+
for (const l of [...listeners]) l(changedRelPath);
|
|
1872
|
+
},
|
|
1873
|
+
};
|
|
1874
|
+
return stub;
|
|
1875
|
+
}
|
|
1876
|
+
|
|
1877
|
+
describe("runRunnerWithLoop — event-push wiring", () => {
|
|
1878
|
+
it("starts the watcher alongside the poll loop when --event-push is on", async () => {
|
|
1879
|
+
const watcher = makeWatcherStub();
|
|
1880
|
+
let triggerShutdown = () => {};
|
|
1881
|
+
const runPass = vi.fn().mockResolvedValue(0);
|
|
1882
|
+
|
|
1883
|
+
const loop = runRunnerWithLoop(
|
|
1884
|
+
["--companies", "--watch", "--event-push", "--hq-root", "/tmp/hq"],
|
|
1885
|
+
{
|
|
1886
|
+
runPass,
|
|
1887
|
+
clock: new FakeClock(),
|
|
1888
|
+
createWatcher: () => watcher,
|
|
1889
|
+
sleep: () => new Promise<void>(() => {}), // never resolves; shutdown ends it
|
|
1890
|
+
onShutdownSignal: (handler) => {
|
|
1891
|
+
triggerShutdown = handler;
|
|
1892
|
+
return () => {};
|
|
1893
|
+
},
|
|
1894
|
+
},
|
|
1895
|
+
);
|
|
1896
|
+
|
|
1897
|
+
// Let the first poll pass run, then shut down.
|
|
1898
|
+
await Promise.resolve();
|
|
1899
|
+
await Promise.resolve();
|
|
1900
|
+
expect(watcher.started).toBe(true);
|
|
1901
|
+
triggerShutdown();
|
|
1902
|
+
await loop;
|
|
1903
|
+
// The poll pass ran at least once (safety-net path still runs).
|
|
1904
|
+
expect(runPass).toHaveBeenCalled();
|
|
1905
|
+
expect(watcher.disposed).toBe(true);
|
|
1906
|
+
});
|
|
1907
|
+
|
|
1908
|
+
it("trigger-on-change: a debounced changed signal runs a targeted company push", async () => {
|
|
1909
|
+
const watcher = makeWatcherStub();
|
|
1910
|
+
const clock = new FakeClock();
|
|
1911
|
+
let triggerShutdown = () => {};
|
|
1912
|
+
const runPass = vi.fn().mockResolvedValue(0);
|
|
1913
|
+
|
|
1914
|
+
const loop = runRunnerWithLoop(
|
|
1915
|
+
["--companies", "--watch", "--event-push", "--hq-root", "/tmp/hq"],
|
|
1916
|
+
{
|
|
1917
|
+
runPass,
|
|
1918
|
+
clock,
|
|
1919
|
+
createWatcher: () => watcher,
|
|
1920
|
+
sleep: () => new Promise<void>(() => {}),
|
|
1921
|
+
onShutdownSignal: (handler) => {
|
|
1922
|
+
triggerShutdown = handler;
|
|
1923
|
+
return () => {};
|
|
1924
|
+
},
|
|
1925
|
+
},
|
|
1926
|
+
);
|
|
1927
|
+
|
|
1928
|
+
// Drain the initial poll pass.
|
|
1929
|
+
await Promise.resolve();
|
|
1930
|
+
await Promise.resolve();
|
|
1931
|
+
runPass.mockClear();
|
|
1932
|
+
|
|
1933
|
+
// Fire a change for an indigo file; advance the (zero) driver window.
|
|
1934
|
+
watcher.emit("companies/indigo/board.json");
|
|
1935
|
+
clock.advance(0);
|
|
1936
|
+
await Promise.resolve();
|
|
1937
|
+
await Promise.resolve();
|
|
1938
|
+
|
|
1939
|
+
const targetedCall = runPass.mock.calls.find((c) =>
|
|
1940
|
+
(c[0] as string[]).includes("--company"),
|
|
1941
|
+
);
|
|
1942
|
+
expect(targetedCall).toBeDefined();
|
|
1943
|
+
expect(targetedCall![0]).toEqual([
|
|
1944
|
+
"--company",
|
|
1945
|
+
"indigo",
|
|
1946
|
+
"--direction",
|
|
1947
|
+
"push",
|
|
1948
|
+
"--hq-root",
|
|
1949
|
+
"/tmp/hq",
|
|
1950
|
+
]);
|
|
1951
|
+
|
|
1952
|
+
triggerShutdown();
|
|
1953
|
+
await loop;
|
|
1954
|
+
});
|
|
1955
|
+
|
|
1956
|
+
it("bare signal (no path) routes the targeted push to the personal vault", async () => {
|
|
1957
|
+
const watcher = makeWatcherStub();
|
|
1958
|
+
const clock = new FakeClock();
|
|
1959
|
+
let triggerShutdown = () => {};
|
|
1960
|
+
const runPass = vi.fn().mockResolvedValue(0);
|
|
1961
|
+
|
|
1962
|
+
const loop = runRunnerWithLoop(
|
|
1963
|
+
["--companies", "--watch", "--event-push", "--hq-root", "/tmp/hq"],
|
|
1964
|
+
{
|
|
1965
|
+
runPass,
|
|
1966
|
+
clock,
|
|
1967
|
+
createWatcher: () => watcher,
|
|
1968
|
+
sleep: () => new Promise<void>(() => {}),
|
|
1969
|
+
onShutdownSignal: (handler) => {
|
|
1970
|
+
triggerShutdown = handler;
|
|
1971
|
+
return () => {};
|
|
1972
|
+
},
|
|
1973
|
+
},
|
|
1974
|
+
);
|
|
1975
|
+
|
|
1976
|
+
await Promise.resolve();
|
|
1977
|
+
await Promise.resolve();
|
|
1978
|
+
runPass.mockClear();
|
|
1979
|
+
|
|
1980
|
+
watcher.emit(); // no path — TreeWatcher's bare signal shape
|
|
1981
|
+
clock.advance(0);
|
|
1982
|
+
await Promise.resolve();
|
|
1983
|
+
await Promise.resolve();
|
|
1984
|
+
|
|
1985
|
+
const personalCall = runPass.mock.calls.find(
|
|
1986
|
+
(c) =>
|
|
1987
|
+
(c[0] as string[]).includes("--companies") &&
|
|
1988
|
+
(c[0] as string[]).includes("--direction"),
|
|
1989
|
+
);
|
|
1990
|
+
expect(personalCall).toBeDefined();
|
|
1991
|
+
expect(personalCall![0]).toEqual([
|
|
1992
|
+
"--companies",
|
|
1993
|
+
"--direction",
|
|
1994
|
+
"push",
|
|
1995
|
+
"--hq-root",
|
|
1996
|
+
"/tmp/hq",
|
|
1997
|
+
]);
|
|
1998
|
+
|
|
1999
|
+
triggerShutdown();
|
|
2000
|
+
await loop;
|
|
2001
|
+
});
|
|
2002
|
+
|
|
2003
|
+
it("overlap guard: a change during an in-flight pass does not start a concurrent pass", async () => {
|
|
2004
|
+
const watcher = makeWatcherStub();
|
|
2005
|
+
const clock = new FakeClock();
|
|
2006
|
+
let triggerShutdown = () => {};
|
|
2007
|
+
|
|
2008
|
+
// A controllable in-flight pass: the first call hangs until we release it.
|
|
2009
|
+
let release: () => void = () => {};
|
|
2010
|
+
const firstGate = new Promise<void>((r) => {
|
|
2011
|
+
release = r;
|
|
2012
|
+
});
|
|
2013
|
+
let callCount = 0;
|
|
2014
|
+
let maxConcurrent = 0;
|
|
2015
|
+
let active = 0;
|
|
2016
|
+
const runPass = vi.fn().mockImplementation(async () => {
|
|
2017
|
+
active++;
|
|
2018
|
+
maxConcurrent = Math.max(maxConcurrent, active);
|
|
2019
|
+
callCount++;
|
|
2020
|
+
if (callCount === 1) await firstGate; // hold the first (poll) pass
|
|
2021
|
+
active--;
|
|
2022
|
+
return 0;
|
|
2023
|
+
});
|
|
2024
|
+
|
|
2025
|
+
const loop = runRunnerWithLoop(
|
|
2026
|
+
["--companies", "--watch", "--event-push", "--hq-root", "/tmp/hq"],
|
|
2027
|
+
{
|
|
2028
|
+
runPass,
|
|
2029
|
+
clock,
|
|
2030
|
+
createWatcher: () => watcher,
|
|
2031
|
+
sleep: () => new Promise<void>(() => {}),
|
|
2032
|
+
onShutdownSignal: (handler) => {
|
|
2033
|
+
triggerShutdown = handler;
|
|
2034
|
+
return () => {};
|
|
2035
|
+
},
|
|
2036
|
+
},
|
|
2037
|
+
);
|
|
2038
|
+
|
|
2039
|
+
// The first poll pass is now in flight and gated.
|
|
2040
|
+
await Promise.resolve();
|
|
2041
|
+
await Promise.resolve();
|
|
2042
|
+
expect(active).toBe(1);
|
|
2043
|
+
|
|
2044
|
+
// Fire a change while the pass is in flight → must be collapsed, not run.
|
|
2045
|
+
watcher.emit("companies/indigo/x.json");
|
|
2046
|
+
clock.advance(0);
|
|
2047
|
+
await Promise.resolve();
|
|
2048
|
+
await Promise.resolve();
|
|
2049
|
+
// Still exactly one active pass; the watcher trigger did not overlap.
|
|
2050
|
+
expect(maxConcurrent).toBe(1);
|
|
2051
|
+
|
|
2052
|
+
// Release the gated pass; the collapsed change re-arms and runs next.
|
|
2053
|
+
release();
|
|
2054
|
+
await Promise.resolve();
|
|
2055
|
+
await Promise.resolve();
|
|
2056
|
+
clock.advance(0);
|
|
2057
|
+
await Promise.resolve();
|
|
2058
|
+
await Promise.resolve();
|
|
2059
|
+
|
|
2060
|
+
// Never more than one pass concurrently across the whole run.
|
|
2061
|
+
expect(maxConcurrent).toBe(1);
|
|
2062
|
+
|
|
2063
|
+
triggerShutdown();
|
|
2064
|
+
await loop;
|
|
2065
|
+
});
|
|
2066
|
+
|
|
2067
|
+
it("poll-still-runs: the poll loop fires passes independent of the watcher", async () => {
|
|
2068
|
+
const watcher = makeWatcherStub();
|
|
2069
|
+
let triggerShutdown = () => {};
|
|
2070
|
+
let passes = 0;
|
|
2071
|
+
// Box the resolver so TS control-flow doesn't narrow it to `null` at the
|
|
2072
|
+
// call sites below (the assignment happens inside a deferred closure).
|
|
2073
|
+
const poll: { resolve: (() => void) | null } = { resolve: null };
|
|
2074
|
+
const runPass = vi.fn().mockImplementation(async () => {
|
|
2075
|
+
passes++;
|
|
2076
|
+
return 0;
|
|
2077
|
+
});
|
|
2078
|
+
// A sleep we can step: resolve once to allow a second poll pass.
|
|
2079
|
+
const sleep = () =>
|
|
2080
|
+
new Promise<void>((resolve) => {
|
|
2081
|
+
poll.resolve = resolve;
|
|
2082
|
+
});
|
|
2083
|
+
|
|
2084
|
+
const loop = runRunnerWithLoop(
|
|
2085
|
+
["--companies", "--watch", "--event-push", "--hq-root", "/tmp/hq"],
|
|
2086
|
+
{
|
|
2087
|
+
runPass,
|
|
2088
|
+
clock: new FakeClock(),
|
|
2089
|
+
createWatcher: () => watcher,
|
|
2090
|
+
sleep,
|
|
2091
|
+
onShutdownSignal: (handler) => {
|
|
2092
|
+
triggerShutdown = handler;
|
|
2093
|
+
return () => {};
|
|
2094
|
+
},
|
|
2095
|
+
},
|
|
2096
|
+
);
|
|
2097
|
+
|
|
2098
|
+
// First poll pass runs, then awaits sleep.
|
|
2099
|
+
await Promise.resolve();
|
|
2100
|
+
await Promise.resolve();
|
|
2101
|
+
expect(passes).toBe(1);
|
|
2102
|
+
|
|
2103
|
+
// Step the poll interval → a second poll pass runs (safety net cadence).
|
|
2104
|
+
poll.resolve?.();
|
|
2105
|
+
await Promise.resolve();
|
|
2106
|
+
await Promise.resolve();
|
|
2107
|
+
expect(passes).toBeGreaterThanOrEqual(2);
|
|
2108
|
+
|
|
2109
|
+
triggerShutdown();
|
|
2110
|
+
// Release any pending sleep so the loop can observe shutdown and exit.
|
|
2111
|
+
poll.resolve?.();
|
|
2112
|
+
await loop;
|
|
2113
|
+
});
|
|
2114
|
+
|
|
2115
|
+
it("clean shutdown: SIGTERM disposes the watcher and resolves the loop with no leaks", async () => {
|
|
2116
|
+
const watcher = makeWatcherStub();
|
|
2117
|
+
const clock = new FakeClock();
|
|
2118
|
+
let triggerShutdown = () => {};
|
|
2119
|
+
let detached = false;
|
|
2120
|
+
const runPass = vi.fn().mockResolvedValue(0);
|
|
2121
|
+
|
|
2122
|
+
const loop = runRunnerWithLoop(
|
|
2123
|
+
["--companies", "--watch", "--event-push", "--hq-root", "/tmp/hq"],
|
|
2124
|
+
{
|
|
2125
|
+
runPass,
|
|
2126
|
+
clock,
|
|
2127
|
+
createWatcher: () => watcher,
|
|
2128
|
+
sleep: () => new Promise<void>(() => {}),
|
|
2129
|
+
onShutdownSignal: (handler) => {
|
|
2130
|
+
triggerShutdown = handler;
|
|
2131
|
+
return () => {
|
|
2132
|
+
detached = true;
|
|
2133
|
+
};
|
|
2134
|
+
},
|
|
2135
|
+
},
|
|
2136
|
+
);
|
|
2137
|
+
|
|
2138
|
+
await Promise.resolve();
|
|
2139
|
+
await Promise.resolve();
|
|
2140
|
+
expect(watcher.started).toBe(true);
|
|
2141
|
+
|
|
2142
|
+
triggerShutdown();
|
|
2143
|
+
const code = await loop;
|
|
2144
|
+
expect(code).toBe(0);
|
|
2145
|
+
expect(watcher.disposed).toBe(true); // watcher torn down
|
|
2146
|
+
expect(detached).toBe(true); // signal handler detached (no leaked listener)
|
|
2147
|
+
// No pending FakeClock timers left behind by the driver's debounce.
|
|
2148
|
+
expect(clock.pendingTimerCount()).toBe(0);
|
|
2149
|
+
});
|
|
2150
|
+
|
|
2151
|
+
it("a non-zero poll pass exit returns immediately (hard error surfaces)", async () => {
|
|
2152
|
+
const watcher = makeWatcherStub();
|
|
2153
|
+
const runPass = vi.fn().mockResolvedValue(2);
|
|
2154
|
+
|
|
2155
|
+
const code = await runRunnerWithLoop(
|
|
2156
|
+
["--companies", "--watch", "--event-push", "--hq-root", "/tmp/hq"],
|
|
2157
|
+
{
|
|
2158
|
+
runPass,
|
|
2159
|
+
clock: new FakeClock(),
|
|
2160
|
+
createWatcher: () => watcher,
|
|
2161
|
+
sleep: () => Promise.resolve(),
|
|
2162
|
+
onShutdownSignal: () => () => {},
|
|
2163
|
+
},
|
|
2164
|
+
);
|
|
2165
|
+
expect(code).toBe(2);
|
|
2166
|
+
expect(watcher.disposed).toBe(true);
|
|
2167
|
+
});
|
|
2168
|
+
|
|
2169
|
+
it("without --event-push, no watcher is created (poll-only safety net)", async () => {
|
|
2170
|
+
let triggerShutdown = () => {};
|
|
2171
|
+
const createWatcher = vi.fn(() => makeWatcherStub());
|
|
2172
|
+
const runPass = vi.fn().mockResolvedValue(0);
|
|
2173
|
+
|
|
2174
|
+
const loop = runRunnerWithLoop(["--companies", "--watch"], {
|
|
2175
|
+
runPass,
|
|
2176
|
+
createWatcher,
|
|
2177
|
+
sleep: () => new Promise<void>(() => {}),
|
|
2178
|
+
onShutdownSignal: (handler) => {
|
|
2179
|
+
triggerShutdown = handler;
|
|
2180
|
+
return () => {};
|
|
2181
|
+
},
|
|
2182
|
+
});
|
|
2183
|
+
|
|
2184
|
+
await Promise.resolve();
|
|
2185
|
+
await Promise.resolve();
|
|
2186
|
+
expect(createWatcher).not.toHaveBeenCalled();
|
|
2187
|
+
expect(runPass).toHaveBeenCalled();
|
|
2188
|
+
triggerShutdown();
|
|
2189
|
+
await loop;
|
|
2190
|
+
});
|
|
1699
2191
|
});
|
|
1700
2192
|
|
|
1701
2193
|
// ---------------------------------------------------------------------------
|
|
@@ -1709,11 +2201,11 @@ beforeEach(() => {
|
|
|
1709
2201
|
// ── resolveDeletePolicy: env-var contract ───────────────────────────────────
|
|
1710
2202
|
//
|
|
1711
2203
|
// `HQ_SYNC_DELETE_POLICY` is the documented rollback knob for the
|
|
1712
|
-
// currency-gated default
|
|
1713
|
-
// allowlist + default so every callsite (both
|
|
1714
|
-
// paths in the runner) gets identical
|
|
1715
|
-
//
|
|
1716
|
-
// instead of in the field.
|
|
2204
|
+
// currency-gated default that became default in 5.25 (after a 5.24 soak).
|
|
2205
|
+
// The helper centralizes the allowlist + default so every callsite (both
|
|
2206
|
+
// the personal and company push paths in the runner) gets identical
|
|
2207
|
+
// semantics. Tests pin the four expected behaviors so a future regression
|
|
2208
|
+
// on the allowlist or default surfaces here instead of in the field.
|
|
1717
2209
|
|
|
1718
2210
|
describe("resolveDeletePolicy", () => {
|
|
1719
2211
|
let originalEnv: string | undefined;
|
|
@@ -1731,12 +2223,11 @@ describe("resolveDeletePolicy", () => {
|
|
|
1731
2223
|
}
|
|
1732
2224
|
});
|
|
1733
2225
|
|
|
1734
|
-
it("defaults to '
|
|
1735
|
-
// 5.24
|
|
1736
|
-
//
|
|
1737
|
-
//
|
|
1738
|
-
|
|
1739
|
-
expect(resolveDeletePolicy()).toBe("owned-only");
|
|
2226
|
+
it("defaults to 'currency-gated' in 5.25 (post-soak default flip)", () => {
|
|
2227
|
+
// 5.24 shipped the code path with `owned-only` as default; 5.25 flips
|
|
2228
|
+
// the default to `currency-gated` after the soak window. Rollback knob
|
|
2229
|
+
// is `HQ_SYNC_DELETE_POLICY=owned-only` for anyone surprised.
|
|
2230
|
+
expect(resolveDeletePolicy()).toBe("currency-gated");
|
|
1740
2231
|
});
|
|
1741
2232
|
|
|
1742
2233
|
it.each(["currency-gated", "owned-only", "all"] as const)(
|
|
@@ -1749,11 +2240,62 @@ describe("resolveDeletePolicy", () => {
|
|
|
1749
2240
|
|
|
1750
2241
|
it("falls back to default on unknown env values (no silent corruption)", () => {
|
|
1751
2242
|
process.env.HQ_SYNC_DELETE_POLICY = "yolo";
|
|
1752
|
-
expect(resolveDeletePolicy()).toBe("
|
|
2243
|
+
expect(resolveDeletePolicy()).toBe("currency-gated");
|
|
1753
2244
|
});
|
|
1754
2245
|
|
|
1755
2246
|
it("treats empty string as unset → default", () => {
|
|
1756
2247
|
process.env.HQ_SYNC_DELETE_POLICY = "";
|
|
1757
|
-
expect(resolveDeletePolicy()).toBe("
|
|
2248
|
+
expect(resolveDeletePolicy()).toBe("currency-gated");
|
|
2249
|
+
});
|
|
2250
|
+
});
|
|
2251
|
+
|
|
2252
|
+
// ── resolveSkipPersonal: flag-OR-env combination ───────────────────────────
|
|
2253
|
+
//
|
|
2254
|
+
// Two inputs combine: the `--skip-personal` CLI flag and the
|
|
2255
|
+
// `HQ_SYNC_SKIP_PERSONAL` env var. Either being truthy skips personal sync.
|
|
2256
|
+
// The flag is the explicit-for-this-invocation knob (menubar passes it when
|
|
2257
|
+
// the user toggled "Sync personal vault" off); the env is the persistent
|
|
2258
|
+
// child-process default. Both surfaces tested so a regression on either
|
|
2259
|
+
// short-circuit path surfaces here.
|
|
2260
|
+
|
|
2261
|
+
describe("resolveSkipPersonal", () => {
|
|
2262
|
+
let originalEnv: string | undefined;
|
|
2263
|
+
|
|
2264
|
+
beforeEach(() => {
|
|
2265
|
+
originalEnv = process.env.HQ_SYNC_SKIP_PERSONAL;
|
|
2266
|
+
delete process.env.HQ_SYNC_SKIP_PERSONAL;
|
|
2267
|
+
});
|
|
2268
|
+
|
|
2269
|
+
afterEach(() => {
|
|
2270
|
+
if (originalEnv === undefined) {
|
|
2271
|
+
delete process.env.HQ_SYNC_SKIP_PERSONAL;
|
|
2272
|
+
} else {
|
|
2273
|
+
process.env.HQ_SYNC_SKIP_PERSONAL = originalEnv;
|
|
2274
|
+
}
|
|
1758
2275
|
});
|
|
2276
|
+
|
|
2277
|
+
it("defaults to false (personal sync enabled, current behavior)", () => {
|
|
2278
|
+
expect(resolveSkipPersonal(false)).toBe(false);
|
|
2279
|
+
});
|
|
2280
|
+
|
|
2281
|
+
it("flag=true short-circuits to true regardless of env", () => {
|
|
2282
|
+
process.env.HQ_SYNC_SKIP_PERSONAL = "no"; // explicit "no" in env
|
|
2283
|
+
expect(resolveSkipPersonal(true)).toBe(true);
|
|
2284
|
+
});
|
|
2285
|
+
|
|
2286
|
+
it.each(["1", "true", "yes", "TRUE", "Yes"])(
|
|
2287
|
+
"env value '%s' (truthy) -> true",
|
|
2288
|
+
(val) => {
|
|
2289
|
+
process.env.HQ_SYNC_SKIP_PERSONAL = val;
|
|
2290
|
+
expect(resolveSkipPersonal(false)).toBe(true);
|
|
2291
|
+
},
|
|
2292
|
+
);
|
|
2293
|
+
|
|
2294
|
+
it.each(["0", "false", "no", "", "unset-equiv"])(
|
|
2295
|
+
"env value '%s' (falsy) -> false",
|
|
2296
|
+
(val) => {
|
|
2297
|
+
process.env.HQ_SYNC_SKIP_PERSONAL = val;
|
|
2298
|
+
expect(resolveSkipPersonal(false)).toBe(false);
|
|
2299
|
+
},
|
|
2300
|
+
);
|
|
1759
2301
|
});
|