@indigoai-us/hq-cloud 6.14.41 → 6.14.43

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.
@@ -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
+ });