@indigoai-us/hq-cloud 6.14.42 → 6.14.44
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-company.d.ts +4 -0
- package/dist/bin/sync-runner-company.d.ts.map +1 -1
- package/dist/bin/sync-runner-company.js +38 -9
- package/dist/bin/sync-runner-company.js.map +1 -1
- package/dist/bin/sync-runner-rollup.d.ts +1 -1
- package/dist/bin/sync-runner-rollup.d.ts.map +1 -1
- package/dist/bin/sync-runner.d.ts +30 -5
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +9 -6
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +180 -21
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/rescue-core.d.ts +13 -0
- package/dist/cli/rescue-core.d.ts.map +1 -1
- package/dist/cli/rescue-core.js +47 -2
- package/dist/cli/rescue-core.js.map +1 -1
- package/dist/cli/rescue-hq-root-guard.test.js +35 -1
- package/dist/cli/rescue-hq-root-guard.test.js.map +1 -1
- package/dist/lib/net-errors.d.ts +9 -6
- package/dist/lib/net-errors.d.ts.map +1 -1
- package/dist/lib/net-errors.js +9 -6
- package/dist/lib/net-errors.js.map +1 -1
- package/dist/lib/net-errors.test.js +9 -1
- package/dist/lib/net-errors.test.js.map +1 -1
- package/package.json +2 -2
- package/src/bin/sync-runner-company.ts +36 -9
- package/src/bin/sync-runner-rollup.ts +1 -1
- package/src/bin/sync-runner.test.ts +246 -23
- package/src/bin/sync-runner.ts +34 -8
- package/src/cli/rescue-core.ts +49 -2
- package/src/cli/rescue-hq-root-guard.test.ts +40 -1
- package/src/lib/net-errors.test.ts +13 -1
- package/src/lib/net-errors.ts +9 -6
- package/test/e2e/sync/transient-company-leg.test.ts +380 -0
package/src/cli/rescue-core.ts
CHANGED
|
@@ -435,6 +435,36 @@ export function runRescue(
|
|
|
435
435
|
}
|
|
436
436
|
}
|
|
437
437
|
|
|
438
|
+
/**
|
|
439
|
+
* True when `root` is usable as a rescue target: an absolute path that is not
|
|
440
|
+
* a bare drive letter. Pure + exported for tests; `platformPath` defaults to
|
|
441
|
+
* the host `path` module so Windows and POSIX runtimes each apply their own
|
|
442
|
+
* absoluteness rules (tests pass `path.win32` / `path.posix` explicitly).
|
|
443
|
+
*
|
|
444
|
+
* Rejecting bare `C:` matters even though `path.win32.isAbsolute("C:")` is
|
|
445
|
+
* already false — the explicit pattern documents the exact field failure and
|
|
446
|
+
* guards against a future helper swap that treats drive-relative paths as
|
|
447
|
+
* absolute.
|
|
448
|
+
*/
|
|
449
|
+
export function isUsableRescueRoot(root: string, platformPath: path.PlatformPath = path): boolean {
|
|
450
|
+
if (/^[A-Za-z]:$/.test(root)) return false;
|
|
451
|
+
return platformPath.isAbsolute(root);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* `fs.realpathSync` that converts failure into the rescue's clean error path
|
|
456
|
+
* (message + ExitError) instead of an uncaught exception. An uncaught throw
|
|
457
|
+
* here is what turned one bad `--hq-root` into a daily crash-loop on Windows.
|
|
458
|
+
*/
|
|
459
|
+
function realpathOrExit(p: string, err: (s: string) => void): string {
|
|
460
|
+
try {
|
|
461
|
+
return fs.realpathSync(p);
|
|
462
|
+
} catch (e) {
|
|
463
|
+
err(`error: cannot resolve --hq-root ${p}: ${e instanceof Error ? e.message : String(e)}\n`);
|
|
464
|
+
throw new ExitError(1);
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
438
468
|
function doRescue(
|
|
439
469
|
cfg: Config,
|
|
440
470
|
env: NodeJS.ProcessEnv,
|
|
@@ -445,16 +475,33 @@ function doRescue(
|
|
|
445
475
|
// --- Resolve HQ root ---
|
|
446
476
|
let hqRoot: string;
|
|
447
477
|
if (cfg.hqRootOverride) {
|
|
478
|
+
// Windows field failure (2026-08-02): the menubar app's daily background
|
|
479
|
+
// core update crash-looped for days because `--hq-root` arrived as the
|
|
480
|
+
// bare drive letter `C:`. `isDir("C:")` is true (drive-relative, resolves
|
|
481
|
+
// against the per-drive cwd) but `fs.realpathSync("C:")` throws EISDIR,
|
|
482
|
+
// so every retry died with an uncaught exception — console-window
|
|
483
|
+
// flicker, git.exe 0xc0000142 dialogs, and no core updates ever landing.
|
|
484
|
+
// Fail closed with a diagnosable message instead: a rescue root must be
|
|
485
|
+
// an absolute directory path, and the error echoes the exact argv this
|
|
486
|
+
// process received so the daily log identifies the caller that mangled
|
|
487
|
+
// the path (the split happens upstream of this process).
|
|
488
|
+
if (!isUsableRescueRoot(cfg.hqRootOverride)) {
|
|
489
|
+
err(
|
|
490
|
+
`error: --hq-root must be an absolute path, got ${JSON.stringify(cfg.hqRootOverride)}.\n` +
|
|
491
|
+
` argv: ${JSON.stringify(process.argv.slice(2))}\n`,
|
|
492
|
+
);
|
|
493
|
+
throw new ExitError(1);
|
|
494
|
+
}
|
|
448
495
|
if (!isDir(cfg.hqRootOverride)) {
|
|
449
496
|
// bash `cd` failure under set -e would abort; mirror as a generic error.
|
|
450
497
|
err(`error: --hq-root ${cfg.hqRootOverride} is not a directory.\n`);
|
|
451
498
|
throw new ExitError(1);
|
|
452
499
|
}
|
|
453
|
-
hqRoot =
|
|
500
|
+
hqRoot = realpathOrExit(cfg.hqRootOverride, err);
|
|
454
501
|
} else {
|
|
455
502
|
// Legacy default assumed the script lived at personal/skills/<skill>/.
|
|
456
503
|
// The package's programmatic callers always pass --hq-root; fall back to cwd.
|
|
457
|
-
hqRoot =
|
|
504
|
+
hqRoot = realpathOrExit(process.cwd(), err);
|
|
458
505
|
}
|
|
459
506
|
|
|
460
507
|
// HQ-root sanity gate — guards against wiping a non-HQ directory. `companies/`
|
|
@@ -23,7 +23,7 @@ import { execFileSync } from "child_process";
|
|
|
23
23
|
import * as fs from "fs";
|
|
24
24
|
import * as os from "os";
|
|
25
25
|
import * as path from "path";
|
|
26
|
-
import { runRescue } from "./rescue-core.js";
|
|
26
|
+
import { isUsableRescueRoot, runRescue } from "./rescue-core.js";
|
|
27
27
|
|
|
28
28
|
function hasGit(): boolean {
|
|
29
29
|
try {
|
|
@@ -190,4 +190,43 @@ exec ${JSON.stringify(realGit)} "$@"
|
|
|
190
190
|
expect(r.status, out).toBe(3);
|
|
191
191
|
expect(out).toContain("does not look like an HQ root");
|
|
192
192
|
});
|
|
193
|
+
|
|
194
|
+
// 2026-08-02 Windows field failure: `--hq-root C:` (a path mangled upstream
|
|
195
|
+
// of this process) crashed with an uncaught EISDIR from fs.realpathSync on
|
|
196
|
+
// every daily background attempt. The gate must fail CLOSED — clean exit 1,
|
|
197
|
+
// no throw — and echo the received argv so the log identifies the caller.
|
|
198
|
+
it("rejects a bare drive letter --hq-root with a clean exit and argv echo", () => {
|
|
199
|
+
const r = rescueDry("C:");
|
|
200
|
+
const out = `${r.stdout}\n${r.stderr}`;
|
|
201
|
+
expect(r.status, out).toBe(1);
|
|
202
|
+
expect(out).toContain("--hq-root must be an absolute path");
|
|
203
|
+
expect(out).toContain('"C:"');
|
|
204
|
+
expect(out).toContain("argv:");
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("rejects a relative --hq-root with a clean exit", () => {
|
|
208
|
+
const r = rescueDry("some/relative/dir");
|
|
209
|
+
const out = `${r.stdout}\n${r.stderr}`;
|
|
210
|
+
expect(r.status, out).toBe(1);
|
|
211
|
+
expect(out).toContain("--hq-root must be an absolute path");
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
describe("isUsableRescueRoot (pure)", () => {
|
|
216
|
+
it("rejects bare drive letters under both path flavors", () => {
|
|
217
|
+
expect(isUsableRescueRoot("C:", path.win32)).toBe(false);
|
|
218
|
+
expect(isUsableRescueRoot("z:", path.win32)).toBe(false);
|
|
219
|
+
expect(isUsableRescueRoot("C:", path.posix)).toBe(false);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("accepts absolute roots for the matching platform", () => {
|
|
223
|
+
expect(isUsableRescueRoot("C:\\Users\\caio\\HQ", path.win32)).toBe(true);
|
|
224
|
+
expect(isUsableRescueRoot("/Users/caio/HQ", path.posix)).toBe(true);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("rejects relative and drive-relative paths", () => {
|
|
228
|
+
expect(isUsableRescueRoot("Users\\caio\\HQ", path.win32)).toBe(false);
|
|
229
|
+
expect(isUsableRescueRoot("C:Users\\caio\\HQ", path.win32)).toBe(false);
|
|
230
|
+
expect(isUsableRescueRoot("some/dir", path.posix)).toBe(false);
|
|
231
|
+
});
|
|
193
232
|
});
|
|
@@ -8,7 +8,7 @@ describe("isTransientNetworkError", () => {
|
|
|
8
8
|
expect(isTransientNetworkError(new TypeError("fetch failed"))).toBe(true);
|
|
9
9
|
});
|
|
10
10
|
|
|
11
|
-
it("treats
|
|
11
|
+
it("treats direct transport `.code`s as transient", () => {
|
|
12
12
|
for (const code of ["ECONNREFUSED", "ENOTFOUND", "EAI_AGAIN", "ETIMEDOUT"]) {
|
|
13
13
|
const err = Object.assign(new Error("boom"), { code });
|
|
14
14
|
expect(isTransientNetworkError(err)).toBe(true);
|
|
@@ -23,6 +23,18 @@ describe("isTransientNetworkError", () => {
|
|
|
23
23
|
expect(isTransientNetworkError(err)).toBe(true);
|
|
24
24
|
});
|
|
25
25
|
|
|
26
|
+
it("walks an AWS SDK UnknownError cause chain to the underlying DNS error", () => {
|
|
27
|
+
const cause = Object.assign(
|
|
28
|
+
new Error("getaddrinfo ENOTFOUND hq-vault-prs-test.s3.us-east-1.amazonaws.com"),
|
|
29
|
+
{ code: "ENOTFOUND", syscall: "getaddrinfo" },
|
|
30
|
+
);
|
|
31
|
+
const err = Object.assign(new Error("UnknownError"), {
|
|
32
|
+
name: "UnknownError",
|
|
33
|
+
cause,
|
|
34
|
+
});
|
|
35
|
+
expect(isTransientNetworkError(err)).toBe(true);
|
|
36
|
+
});
|
|
37
|
+
|
|
26
38
|
it("walks AggregateError.errors (happy-eyeballs)", () => {
|
|
27
39
|
const agg = Object.assign(new AggregateError([
|
|
28
40
|
Object.assign(new Error("v4"), { code: "ECONNREFUSED" }),
|
package/src/lib/net-errors.ts
CHANGED
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
* Transient network-failure classification for the sync runner.
|
|
3
3
|
*
|
|
4
4
|
* The auto-sync watcher runs an unattended poll loop. At the top of every pass
|
|
5
|
-
* it calls `GET /membership/me` to resolve which companies to sync
|
|
6
|
-
* machine is briefly offline (wifi drop,
|
|
7
|
-
*
|
|
5
|
+
* it calls `GET /membership/me` to resolve which companies to sync, then it
|
|
6
|
+
* runs a per-company fanout. When the machine is briefly offline (wifi drop,
|
|
7
|
+
* waking from sleep, a DNS blip, the vault API or a vault S3 endpoint
|
|
8
|
+
* momentarily unreachable), either boundary can fail at the transport layer —
|
|
8
9
|
* Node's `fetch` throws `TypeError: fetch failed` with the real cause (an
|
|
9
10
|
* `ECONNREFUSED` / `ENOTFOUND` / `ETIMEDOUT` / … Error) on `.cause`.
|
|
10
11
|
*
|
|
@@ -12,9 +13,11 @@
|
|
|
12
13
|
* back. But the runner used to `return 1` for it, the watch loop propagated
|
|
13
14
|
* that non-zero exit, the process exited, and the menubar supervisor reported
|
|
14
15
|
* "auto-sync watcher exited unexpectedly (code=Some(1))" for every blip — the
|
|
15
|
-
* HQ-SYNC-1W cluster.
|
|
16
|
-
*
|
|
17
|
-
*
|
|
16
|
+
* HQ-SYNC-1W cluster. A company-leg failure was also previously counted as an
|
|
17
|
+
* exit-2 partial sync, producing the HQ-SYNC-X symptom even though it was just
|
|
18
|
+
* retryable DNS/transport loss. We classify both boundaries so the watch loop
|
|
19
|
+
* can stay alive and retry instead of surfacing a false crash, while a one-shot
|
|
20
|
+
* `hq sync` still exits non-zero so a human running it by hand sees the failure.
|
|
18
21
|
*/
|
|
19
22
|
|
|
20
23
|
/**
|
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Artifact-level regression for HQ-SYNC-X.
|
|
3
|
+
*
|
|
4
|
+
* This starts the compiled runner as a child process. The local vault stub
|
|
5
|
+
* completes authentication, membership discovery, entity lookup, and the
|
|
6
|
+
* normal company-vault list. Only the personal vault's direct-S3 endpoint uses
|
|
7
|
+
* an unresolvable `.invalid` host. That makes the real sync function reject
|
|
8
|
+
* from its fanout leg, rather than synthesizing a per-file error or failing at
|
|
9
|
+
* the initial GET /membership/me boundary (which already returns 75).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { createServer, type Server } from "node:http";
|
|
13
|
+
import { once } from "node:events";
|
|
14
|
+
import { access, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
15
|
+
import { tmpdir } from "node:os";
|
|
16
|
+
import path from "node:path";
|
|
17
|
+
import { fileURLToPath } from "node:url";
|
|
18
|
+
import { spawn } from "node:child_process";
|
|
19
|
+
|
|
20
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
21
|
+
|
|
22
|
+
const repoRoot = fileURLToPath(new URL("../../../", import.meta.url));
|
|
23
|
+
const artifactPath = process.env.HQ_CLOUD_E2E_ARTIFACT
|
|
24
|
+
? path.resolve(process.env.HQ_CLOUD_E2E_ARTIFACT)
|
|
25
|
+
: path.join(repoRoot, "dist", "bin", "sync-runner.js");
|
|
26
|
+
|
|
27
|
+
const companyUid = "cmp_clean_e2e";
|
|
28
|
+
const companySlug = "clean-e2e";
|
|
29
|
+
const personalUid = "prs_transient_e2e";
|
|
30
|
+
const invalidS3Endpoint = "http://hq-vault-transient-company-leg.invalid";
|
|
31
|
+
|
|
32
|
+
let fixtureRoot: string | undefined;
|
|
33
|
+
let hqRoot: string;
|
|
34
|
+
let testHome: string;
|
|
35
|
+
let server: Server | undefined;
|
|
36
|
+
let vaultUrl: string;
|
|
37
|
+
const requests: string[] = [];
|
|
38
|
+
let personListReads = 0;
|
|
39
|
+
|
|
40
|
+
function json(response: import("node:http").ServerResponse, body: unknown): void {
|
|
41
|
+
response.writeHead(200, { "content-type": "application/json" });
|
|
42
|
+
response.end(JSON.stringify(body));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function ensureArtifact(): Promise<void> {
|
|
46
|
+
try {
|
|
47
|
+
await access(artifactPath);
|
|
48
|
+
return;
|
|
49
|
+
} catch (err) {
|
|
50
|
+
// An explicit artifact path is used only by the base-commit proof. Do not
|
|
51
|
+
// hide a bad path there by building the candidate artifact instead.
|
|
52
|
+
if (process.env.HQ_CLOUD_E2E_ARTIFACT) throw err;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
await new Promise<void>((resolve, reject) => {
|
|
56
|
+
const npm = process.platform === "win32" ? "npm.cmd" : "npm";
|
|
57
|
+
const child = spawn(npm, ["run", "build"], {
|
|
58
|
+
cwd: repoRoot,
|
|
59
|
+
stdio: "inherit",
|
|
60
|
+
});
|
|
61
|
+
child.once("error", reject);
|
|
62
|
+
child.once("close", (code) => {
|
|
63
|
+
if (code === 0) resolve();
|
|
64
|
+
else reject(new Error(`npm run build exited ${code ?? "without a status"}`));
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
await access(artifactPath);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function runArtifact(): Promise<{
|
|
71
|
+
code: number | null;
|
|
72
|
+
stdout: string;
|
|
73
|
+
stderr: string;
|
|
74
|
+
}> {
|
|
75
|
+
return new Promise((resolve, reject) => {
|
|
76
|
+
const child = spawn(
|
|
77
|
+
process.execPath,
|
|
78
|
+
[artifactPath, "--companies", "--hq-root", hqRoot, "--json"],
|
|
79
|
+
{
|
|
80
|
+
cwd: path.dirname(artifactPath),
|
|
81
|
+
env: {
|
|
82
|
+
...process.env,
|
|
83
|
+
HOME: testHome,
|
|
84
|
+
HQ_MACHINE_ID: "e2e-transient-company-leg",
|
|
85
|
+
HQ_QMD_REINDEX_ON_SYNC: "0",
|
|
86
|
+
HQ_STATE_DIR: path.join(fixtureRoot, "state"),
|
|
87
|
+
HQ_VAULT_API_URL: vaultUrl,
|
|
88
|
+
AWS_ENDPOINT_URL: invalidS3Endpoint,
|
|
89
|
+
},
|
|
90
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
91
|
+
},
|
|
92
|
+
);
|
|
93
|
+
let stdout = "";
|
|
94
|
+
let stderr = "";
|
|
95
|
+
child.stdout.on("data", (chunk: Buffer) => {
|
|
96
|
+
stdout += chunk.toString();
|
|
97
|
+
});
|
|
98
|
+
child.stderr.on("data", (chunk: Buffer) => {
|
|
99
|
+
stderr += chunk.toString();
|
|
100
|
+
});
|
|
101
|
+
const timeout = setTimeout(() => {
|
|
102
|
+
child.kill("SIGTERM");
|
|
103
|
+
reject(new Error("compiled sync runner did not exit within 15 seconds"));
|
|
104
|
+
}, 15_000);
|
|
105
|
+
child.once("error", (err) => {
|
|
106
|
+
clearTimeout(timeout);
|
|
107
|
+
reject(err);
|
|
108
|
+
});
|
|
109
|
+
child.once("close", (code) => {
|
|
110
|
+
clearTimeout(timeout);
|
|
111
|
+
resolve({ code, stdout, stderr });
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function runWatchArtifact(): Promise<{
|
|
117
|
+
code: number | null;
|
|
118
|
+
signal: NodeJS.Signals | null;
|
|
119
|
+
stdout: string;
|
|
120
|
+
stderr: string;
|
|
121
|
+
}> {
|
|
122
|
+
return new Promise((resolve, reject) => {
|
|
123
|
+
const child = spawn(
|
|
124
|
+
process.execPath,
|
|
125
|
+
[
|
|
126
|
+
artifactPath,
|
|
127
|
+
"--companies",
|
|
128
|
+
"--hq-root",
|
|
129
|
+
hqRoot,
|
|
130
|
+
"--json",
|
|
131
|
+
"--watch",
|
|
132
|
+
"--poll-remote-ms",
|
|
133
|
+
"25",
|
|
134
|
+
],
|
|
135
|
+
{
|
|
136
|
+
cwd: path.dirname(artifactPath),
|
|
137
|
+
env: {
|
|
138
|
+
...process.env,
|
|
139
|
+
HOME: testHome,
|
|
140
|
+
HQ_MACHINE_ID: "e2e-transient-company-leg",
|
|
141
|
+
HQ_QMD_REINDEX_ON_SYNC: "0",
|
|
142
|
+
HQ_STATE_DIR: path.join(fixtureRoot, "state"),
|
|
143
|
+
HQ_VAULT_API_URL: vaultUrl,
|
|
144
|
+
AWS_ENDPOINT_URL: invalidS3Endpoint,
|
|
145
|
+
},
|
|
146
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
147
|
+
},
|
|
148
|
+
);
|
|
149
|
+
let stdout = "";
|
|
150
|
+
let stderr = "";
|
|
151
|
+
let stopping = false;
|
|
152
|
+
const stop = () => {
|
|
153
|
+
if (stopping || child.exitCode !== null) return;
|
|
154
|
+
stopping = true;
|
|
155
|
+
child.kill("SIGTERM");
|
|
156
|
+
};
|
|
157
|
+
const timeout = setTimeout(() => {
|
|
158
|
+
stop();
|
|
159
|
+
reject(new Error("watching compiled sync runner did not complete two polls within 20 seconds"));
|
|
160
|
+
}, 20_000);
|
|
161
|
+
child.stdout.on("data", (chunk: Buffer) => {
|
|
162
|
+
stdout += chunk.toString();
|
|
163
|
+
const completePasses = (stdout.match(/"type":"all-complete"/g) ?? []).length;
|
|
164
|
+
if (completePasses >= 2) stop();
|
|
165
|
+
});
|
|
166
|
+
child.stderr.on("data", (chunk: Buffer) => {
|
|
167
|
+
stderr += chunk.toString();
|
|
168
|
+
});
|
|
169
|
+
child.once("error", (err) => {
|
|
170
|
+
clearTimeout(timeout);
|
|
171
|
+
reject(err);
|
|
172
|
+
});
|
|
173
|
+
child.once("close", (code, signal) => {
|
|
174
|
+
clearTimeout(timeout);
|
|
175
|
+
resolve({ code, signal, stdout, stderr });
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
beforeEach(async () => {
|
|
181
|
+
await ensureArtifact();
|
|
182
|
+
fixtureRoot = await mkdtemp(path.join(tmpdir(), "hqcloud-transient-company-leg-e2e-"));
|
|
183
|
+
hqRoot = path.join(fixtureRoot, "hq");
|
|
184
|
+
testHome = path.join(fixtureRoot, "home");
|
|
185
|
+
await mkdir(path.join(hqRoot, "companies", companySlug), { recursive: true });
|
|
186
|
+
await mkdir(path.join(testHome, ".hq"), { recursive: true });
|
|
187
|
+
const fakeJwtPayload = Buffer.from(
|
|
188
|
+
JSON.stringify({ sub: "e2e-subject", exp: Math.floor(Date.now() / 1000) + 3600 }),
|
|
189
|
+
).toString("base64url");
|
|
190
|
+
await writeFile(
|
|
191
|
+
path.join(testHome, ".hq", "cognito-tokens.json"),
|
|
192
|
+
JSON.stringify({
|
|
193
|
+
accessToken: `header.${fakeJwtPayload}.signature`,
|
|
194
|
+
idToken: `header.${fakeJwtPayload}.signature`,
|
|
195
|
+
refreshToken: "e2e-only",
|
|
196
|
+
expiresAt: Date.now() + 60 * 60 * 1000,
|
|
197
|
+
tokenType: "Bearer",
|
|
198
|
+
}),
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
requests.length = 0;
|
|
202
|
+
personListReads = 0;
|
|
203
|
+
server = createServer((request, response) => {
|
|
204
|
+
const url = new URL(request.url ?? "/", "http://127.0.0.1");
|
|
205
|
+
requests.push(`${request.method} ${url.pathname}`);
|
|
206
|
+
if (request.method === "GET" && url.pathname === "/membership/me") {
|
|
207
|
+
json(response, { memberships: [{ companyUid }] });
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
if (request.method === "GET" && url.pathname === `/entity/${companyUid}`) {
|
|
211
|
+
json(response, {
|
|
212
|
+
entity: {
|
|
213
|
+
uid: companyUid,
|
|
214
|
+
slug: companySlug,
|
|
215
|
+
type: "company",
|
|
216
|
+
bucketName: "hq-vault-e2e",
|
|
217
|
+
status: "active",
|
|
218
|
+
},
|
|
219
|
+
});
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
if (request.method === "GET" && url.pathname === `/entity/${personalUid}`) {
|
|
223
|
+
json(response, {
|
|
224
|
+
entity: {
|
|
225
|
+
uid: personalUid,
|
|
226
|
+
slug: "personal-e2e",
|
|
227
|
+
type: "person",
|
|
228
|
+
bucketName: "hq-vault-e2e-personal",
|
|
229
|
+
status: "active",
|
|
230
|
+
},
|
|
231
|
+
});
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
if (request.method === "GET" && url.pathname === "/v1/files/list") {
|
|
235
|
+
json(response, {
|
|
236
|
+
objects: [],
|
|
237
|
+
cursor: null,
|
|
238
|
+
truncated: false,
|
|
239
|
+
});
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
if (request.method === "POST" && url.pathname === "/sts/vend-self") {
|
|
243
|
+
json(response, {
|
|
244
|
+
credentials: {
|
|
245
|
+
accessKeyId: "e2e-access-key",
|
|
246
|
+
secretAccessKey: "e2e-secret-key",
|
|
247
|
+
sessionToken: "e2e-session-token",
|
|
248
|
+
expiration: "2026-07-30T01:00:00.000Z",
|
|
249
|
+
},
|
|
250
|
+
expiresAt: "2026-07-30T01:00:00.000Z",
|
|
251
|
+
});
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
if (request.method === "GET" && url.pathname === "/entity/by-type/person") {
|
|
255
|
+
personListReads += 1;
|
|
256
|
+
json(response, {
|
|
257
|
+
entities:
|
|
258
|
+
personListReads % 2 === 1
|
|
259
|
+
? [
|
|
260
|
+
{
|
|
261
|
+
uid: personalUid,
|
|
262
|
+
slug: "personal-e2e",
|
|
263
|
+
type: "person",
|
|
264
|
+
bucketName: "hq-vault-e2e-personal",
|
|
265
|
+
status: "active",
|
|
266
|
+
},
|
|
267
|
+
]
|
|
268
|
+
: [],
|
|
269
|
+
});
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
if (request.method === "GET" && url.pathname === "/membership/pending-by-email") {
|
|
273
|
+
json(response, { invites: [] });
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
if (request.method === "POST") {
|
|
277
|
+
json(response, { ok: true, written: 0, skipped: [] });
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
response.writeHead(404, { "content-type": "application/json" });
|
|
281
|
+
response.end(JSON.stringify({ error: `unexpected ${request.method} ${url.pathname}` }));
|
|
282
|
+
});
|
|
283
|
+
server.listen(0, "127.0.0.1");
|
|
284
|
+
await once(server, "listening");
|
|
285
|
+
const address = server.address();
|
|
286
|
+
if (!address || typeof address === "string") {
|
|
287
|
+
throw new Error("e2e vault stub did not bind a TCP port");
|
|
288
|
+
}
|
|
289
|
+
vaultUrl = `http://127.0.0.1:${address.port}`;
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
afterEach(async () => {
|
|
293
|
+
if (server?.listening) {
|
|
294
|
+
server.close();
|
|
295
|
+
await once(server, "close");
|
|
296
|
+
}
|
|
297
|
+
if (fixtureRoot) {
|
|
298
|
+
await rm(fixtureRoot, { recursive: true, force: true });
|
|
299
|
+
}
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
describe("compiled sync runner transient company leg (HQ-SYNC-X)", () => {
|
|
303
|
+
it(
|
|
304
|
+
"returns 75 and emits transient-network when only the personal vault S3 leg cannot resolve",
|
|
305
|
+
async () => {
|
|
306
|
+
const result = await runArtifact();
|
|
307
|
+
|
|
308
|
+
expect(
|
|
309
|
+
result.code,
|
|
310
|
+
`stdout=${result.stdout}\nstderr=${result.stderr}`,
|
|
311
|
+
).toBe(75);
|
|
312
|
+
expect(requests).toEqual(
|
|
313
|
+
expect.arrayContaining([
|
|
314
|
+
"GET /membership/me",
|
|
315
|
+
`GET /entity/${companyUid}`,
|
|
316
|
+
"GET /v1/files/list",
|
|
317
|
+
"POST /sts/vend-self",
|
|
318
|
+
]),
|
|
319
|
+
);
|
|
320
|
+
const stdoutEvents = result.stdout
|
|
321
|
+
.trim()
|
|
322
|
+
.split("\n")
|
|
323
|
+
.filter(Boolean)
|
|
324
|
+
.map((line) => JSON.parse(line) as Record<string, unknown>);
|
|
325
|
+
expect(
|
|
326
|
+
stdoutEvents.filter((event) => event.type === "transient-network"),
|
|
327
|
+
).toEqual([
|
|
328
|
+
expect.objectContaining({
|
|
329
|
+
company: "personal",
|
|
330
|
+
path: "(company)",
|
|
331
|
+
message: expect.stringContaining("ENOTFOUND"),
|
|
332
|
+
}),
|
|
333
|
+
]);
|
|
334
|
+
expect(
|
|
335
|
+
stdoutEvents.some(
|
|
336
|
+
(event) => event.type === "error" && event.path === "(company)",
|
|
337
|
+
),
|
|
338
|
+
).toBe(false);
|
|
339
|
+
expect(stdoutEvents.find((event) => event.type === "all-complete")).toEqual(
|
|
340
|
+
expect.objectContaining({
|
|
341
|
+
errors: [],
|
|
342
|
+
transient: [expect.objectContaining({ company: "personal" })],
|
|
343
|
+
}),
|
|
344
|
+
);
|
|
345
|
+
expect(result.stderr).not.toContain('"path":"(company)"');
|
|
346
|
+
},
|
|
347
|
+
30_000,
|
|
348
|
+
);
|
|
349
|
+
|
|
350
|
+
it(
|
|
351
|
+
"stays alive through two transient company-leg passes until SIGTERM stops it",
|
|
352
|
+
async () => {
|
|
353
|
+
const result = await runWatchArtifact();
|
|
354
|
+
expect(
|
|
355
|
+
{ code: result.code, signal: result.signal },
|
|
356
|
+
`stdout=${result.stdout}\nstderr=${result.stderr}`,
|
|
357
|
+
).toEqual({ code: null, signal: "SIGTERM" });
|
|
358
|
+
|
|
359
|
+
const stdoutEvents = result.stdout
|
|
360
|
+
.trim()
|
|
361
|
+
.split("\n")
|
|
362
|
+
.filter(Boolean)
|
|
363
|
+
.map((line) => JSON.parse(line) as Record<string, unknown>);
|
|
364
|
+
expect(
|
|
365
|
+
stdoutEvents.filter((event) => event.type === "all-complete"),
|
|
366
|
+
).toHaveLength(2);
|
|
367
|
+
expect(
|
|
368
|
+
stdoutEvents.filter(
|
|
369
|
+
(event) =>
|
|
370
|
+
event.type === "transient-network" && event.company === "personal",
|
|
371
|
+
),
|
|
372
|
+
).toHaveLength(2);
|
|
373
|
+
expect(result.stderr).toContain(
|
|
374
|
+
"watch pass skipped — transient network failure; retrying next poll",
|
|
375
|
+
);
|
|
376
|
+
expect(result.stderr).not.toContain('"path":"(company)"');
|
|
377
|
+
},
|
|
378
|
+
30_000,
|
|
379
|
+
);
|
|
380
|
+
});
|