@indigoai-us/hq-cli 5.47.16 → 5.48.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.
Files changed (37) hide show
  1. package/.github/workflows/ci.yml +16 -20
  2. package/.github/workflows/publish.yml +26 -101
  3. package/dist/commands/feedback.d.ts +2 -0
  4. package/dist/commands/feedback.js +24 -2
  5. package/dist/commands/files.d.ts +19 -0
  6. package/dist/commands/files.js +37 -3
  7. package/dist/commands/meetings.js +50 -2
  8. package/dist/commands/secrets.js +25 -2
  9. package/dist/run/hq-plugin.js +9 -2
  10. package/dist/sentry-dsn.generated.d.ts +1 -1
  11. package/dist/sentry-dsn.generated.js +1 -1
  12. package/dist/sentry.js +7 -3
  13. package/dist/utils/feedback-diagnostics.d.ts +7 -0
  14. package/dist/utils/feedback-diagnostics.js +4 -2
  15. package/dist/utils/feedback-screenshots.d.ts +23 -0
  16. package/dist/utils/feedback-screenshots.js +98 -0
  17. package/dist/utils/feedback-versions.d.ts +34 -0
  18. package/dist/utils/feedback-versions.js +50 -0
  19. package/package.json +4 -2
  20. package/src/commands/feedback.test.ts +44 -0
  21. package/src/commands/feedback.ts +46 -13
  22. package/src/commands/files-delete.test.ts +132 -0
  23. package/src/commands/files.ts +42 -1
  24. package/src/commands/meetings.test.ts +163 -0
  25. package/src/commands/meetings.ts +60 -0
  26. package/src/commands/secrets.test.ts +80 -0
  27. package/src/commands/secrets.ts +35 -0
  28. package/src/run/hq-plugin.test.ts +39 -0
  29. package/src/run/hq-plugin.ts +7 -0
  30. package/src/sentry.ts +5 -1
  31. package/src/utils/feedback-diagnostics.test.ts +11 -0
  32. package/src/utils/feedback-diagnostics.ts +8 -0
  33. package/src/utils/feedback-screenshots.test.ts +134 -0
  34. package/src/utils/feedback-screenshots.ts +124 -0
  35. package/src/utils/feedback-versions.test.ts +98 -0
  36. package/src/utils/feedback-versions.ts +68 -0
  37. package/test/helpers/vault-service-mock.ts +6 -2
@@ -0,0 +1,163 @@
1
+ import { Command } from "commander";
2
+ import {
3
+ afterEach,
4
+ beforeEach,
5
+ describe,
6
+ expect,
7
+ it,
8
+ vi,
9
+ type MockInstance,
10
+ } from "vitest";
11
+
12
+ vi.mock("../utils/cognito-session.js", async (importOriginal) => {
13
+ const original = (await importOriginal()) as Record<string, unknown>;
14
+ return {
15
+ ...original,
16
+ ensureCognitoToken: vi.fn(async () => "test-token"),
17
+ };
18
+ });
19
+
20
+ vi.mock("../utils/vault-api.js", async (importOriginal) => {
21
+ const original = (await importOriginal()) as Record<string, unknown>;
22
+ return {
23
+ ...original,
24
+ getCompanyUid: vi.fn(async (_token: string, slug: string) => `cmp_${slug}`),
25
+ vaultApiFetch: vi.fn(async () =>
26
+ new Response(JSON.stringify({ appliedToSeries: true }), {
27
+ status: 200,
28
+ headers: { "Content-Type": "application/json" },
29
+ }),
30
+ ),
31
+ };
32
+ });
33
+
34
+ import { registerMeetingsCommand } from "./meetings.js";
35
+ import { getCompanyUid, vaultApiFetch } from "../utils/vault-api.js";
36
+
37
+ let logSpy: MockInstance<typeof console.log>;
38
+ let errSpy: MockInstance<typeof console.error>;
39
+
40
+ beforeEach(() => {
41
+ vi.clearAllMocks();
42
+ logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined);
43
+ errSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
44
+ });
45
+
46
+ afterEach(() => {
47
+ vi.restoreAllMocks();
48
+ });
49
+
50
+ function buildProgram(): Command {
51
+ const program = new Command();
52
+ program.exitOverride();
53
+ program.configureOutput({
54
+ writeOut: () => undefined,
55
+ writeErr: () => undefined,
56
+ });
57
+ registerMeetingsCommand(program);
58
+ return program;
59
+ }
60
+
61
+ function jsonRes(body: unknown, status = 200): Response {
62
+ return new Response(JSON.stringify(body), {
63
+ status,
64
+ headers: { "Content-Type": "application/json" },
65
+ });
66
+ }
67
+
68
+ describe("meetings set-company", () => {
69
+ it("POSTs the resolved company id and applies to the recurring series by default", async () => {
70
+ vi.mocked(vaultApiFetch).mockResolvedValueOnce(
71
+ jsonRes({ appliedToSeries: true }),
72
+ );
73
+
74
+ const program = buildProgram();
75
+ await program.parseAsync([
76
+ "node",
77
+ "hq",
78
+ "meetings",
79
+ "set-company",
80
+ "meeting-123456",
81
+ "--company",
82
+ "acme",
83
+ ]);
84
+
85
+ expect(getCompanyUid).toHaveBeenCalledWith("test-token", "acme");
86
+ expect(vaultApiFetch).toHaveBeenCalledWith({
87
+ token: "test-token",
88
+ method: "POST",
89
+ path: "/v1/meetings/meeting-123456/company",
90
+ body: { companyId: "cmp_acme", applyToSeries: true },
91
+ });
92
+ expect(errSpy).not.toHaveBeenCalled();
93
+ });
94
+
95
+ it("sends unknown without resolving a target company", async () => {
96
+ vi.mocked(vaultApiFetch).mockResolvedValueOnce(
97
+ jsonRes({ appliedToSeries: true }),
98
+ );
99
+
100
+ const program = buildProgram();
101
+ await program.parseAsync([
102
+ "node",
103
+ "hq",
104
+ "meetings",
105
+ "set-company",
106
+ "meeting-123456",
107
+ "--company",
108
+ "unknown",
109
+ ]);
110
+
111
+ expect(getCompanyUid).not.toHaveBeenCalled();
112
+ expect(vaultApiFetch).toHaveBeenCalledWith({
113
+ token: "test-token",
114
+ method: "POST",
115
+ path: "/v1/meetings/meeting-123456/company",
116
+ body: { companyId: "unknown", applyToSeries: true },
117
+ });
118
+ });
119
+
120
+ it("sends applyToSeries false when --no-series is passed", async () => {
121
+ vi.mocked(vaultApiFetch).mockResolvedValueOnce(
122
+ jsonRes({ appliedToSeries: false }),
123
+ );
124
+
125
+ const program = buildProgram();
126
+ await program.parseAsync([
127
+ "node",
128
+ "hq",
129
+ "meetings",
130
+ "set-company",
131
+ "meeting-123456",
132
+ "--company",
133
+ "acme",
134
+ "--no-series",
135
+ ]);
136
+
137
+ expect(vaultApiFetch).toHaveBeenCalledWith({
138
+ token: "test-token",
139
+ method: "POST",
140
+ path: "/v1/meetings/meeting-123456/company",
141
+ body: { companyId: "cmp_acme", applyToSeries: false },
142
+ });
143
+ });
144
+
145
+ it("prints the raw response when --json is passed", async () => {
146
+ const body = { meetingId: "meeting-123456", companyId: "cmp_acme" };
147
+ vi.mocked(vaultApiFetch).mockResolvedValueOnce(jsonRes(body));
148
+
149
+ const program = buildProgram();
150
+ await program.parseAsync([
151
+ "node",
152
+ "hq",
153
+ "meetings",
154
+ "--json",
155
+ "set-company",
156
+ "meeting-123456",
157
+ "--company",
158
+ "acme",
159
+ ]);
160
+
161
+ expect(logSpy).toHaveBeenCalledWith(JSON.stringify(body, null, 2));
162
+ });
163
+ });
@@ -299,6 +299,66 @@ export function registerMeetingsCommand(program: Command): void {
299
299
  }
300
300
  });
301
301
 
302
+ // ── hq meetings set-company <id> ──────────────────────────────────
303
+
304
+ meetings
305
+ .command("set-company <meetingId>")
306
+ .description("Set or change the company a meeting is attributed to (use 'unknown' to clear)")
307
+ .option("--company <slug>", "Target company slug, or 'unknown'/'personal' to clear attribution")
308
+ .option("--no-series", "Apply only to this occurrence, not the whole recurring series")
309
+ .action(async (rawId: string, cmdOpts: { company?: string; series?: boolean }) => {
310
+ try {
311
+ const token = await ensureCognitoToken();
312
+ // `--company` is shared with the parent `meetings` command, so commander
313
+ // may bind it to either level depending on position — accept both.
314
+ const target = cmdOpts.company ?? (meetings.opts().company as string | undefined);
315
+ if (!target) {
316
+ console.error(
317
+ chalk.red("Error:"),
318
+ "a target company is required: --company <slug> (or 'unknown'/'personal' to clear)",
319
+ );
320
+ process.exit(1);
321
+ }
322
+
323
+ // The bot record is person-keyed, so no company scope is needed to
324
+ // resolve the meeting id; pass an empty scope.
325
+ const meetingId = await resolveShortId(token, rawId, {});
326
+
327
+ const lc = target.toLowerCase();
328
+ const companyId =
329
+ lc === "unknown" || lc === "personal"
330
+ ? "unknown"
331
+ : await getCompanyUid(token, target);
332
+
333
+ const applyToSeries = cmdOpts.series !== false;
334
+
335
+ const res = await vaultApiFetch({
336
+ token,
337
+ method: "POST",
338
+ path: `/v1/meetings/${encodeURIComponent(meetingId)}/company`,
339
+ body: { companyId, applyToSeries },
340
+ });
341
+ if (!res.ok) await handleApiError(res);
342
+ const data = (await res.json()) as {
343
+ appliedToSeries?: boolean;
344
+ [key: string]: unknown;
345
+ };
346
+
347
+ if (meetings.opts().json) {
348
+ console.log(JSON.stringify(data, null, 2));
349
+ return;
350
+ }
351
+
352
+ const where = companyId === "unknown" ? "unattributed (personal)" : companyId;
353
+ console.log(chalk.green(`\n✓ Meeting ${chalk.cyan(meetingId)} attributed to ${chalk.bold(where)}.`));
354
+ if (data && data.appliedToSeries) console.log(chalk.dim(" Future occurrences of this recurring series will inherit this attribution."));
355
+ console.log();
356
+ } catch (err) {
357
+ console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
358
+ process.exit(1);
359
+ }
360
+ });
361
+
302
362
  // ── hq meetings search <query> ─────────────────────────────────────
303
363
 
304
364
  meetings
@@ -160,6 +160,86 @@ describe("secrets exists (HQ-4H HEAD probe)", () => {
160
160
  });
161
161
  });
162
162
 
163
+ // US-003 (secrets-server-proxy): the CLI surfaces the SERVER's refusal of a
164
+ // high-security ("nuclear") secret on the local-injection path as a clear,
165
+ // actionable error pointing at the proxy — and never prints the value. The
166
+ // server-side deny is the real control (it returns 403 + highSecurity:true and
167
+ // NO plaintext); these tests assert the CLI's surfacing behavior.
168
+ describe("US-003 — CLI refuses high-security secrets on local injection", () => {
169
+ // The server's 403 refusal shape for a high-security secret.
170
+ function highSecurityDenied(): Response {
171
+ return new Response(
172
+ JSON.stringify({
173
+ error:
174
+ "Secret 'ANTHROPIC_API_KEY' is high-security and cannot be retrieved via local injection. Use the server-side proxy.",
175
+ highSecurity: true,
176
+ }),
177
+ { status: 403, headers: { "Content-Type": "application/json" } },
178
+ );
179
+ }
180
+
181
+ let exitSpy: MockInstance<typeof process.exit>;
182
+ beforeEach(() => {
183
+ exitSpy = vi.spyOn(process, "exit").mockImplementation((() => {
184
+ throw new Error("__exit__");
185
+ }) as never);
186
+ });
187
+
188
+ it("E2E: `secrets get --reveal` is denied — clear proxy-pointing error, no value printed", async () => {
189
+ vi.mocked(vaultApiFetch).mockResolvedValueOnce(highSecurityDenied());
190
+
191
+ const program = buildProgram();
192
+ try {
193
+ await program.parseAsync([
194
+ "node",
195
+ "hq",
196
+ "secrets",
197
+ "get",
198
+ "ANTHROPIC_API_KEY",
199
+ "--reveal",
200
+ ]);
201
+ } catch {
202
+ // exit sentinel
203
+ }
204
+ const exitCode = exitSpy.mock.calls[0]?.[0] as number | undefined;
205
+
206
+ expect(exitCode).toBe(1);
207
+ // A clear, actionable error mentioning high-security + the proxy.
208
+ const errText = errSpy.mock.calls.flat().join(" ");
209
+ expect(errText).toMatch(/high-security/i);
210
+ expect(errText).toMatch(/proxy/i);
211
+ // The value is NEVER printed — no "Value:" line carrying plaintext.
212
+ const logText = logSpy.mock.calls.flat().join(" ");
213
+ expect(logText).not.toMatch(/sk-ant/i);
214
+ });
215
+
216
+ it("E2E: `secrets exec --only <name> -- env` is denied — proxy-pointing error, command not run", async () => {
217
+ vi.mocked(vaultApiFetch).mockResolvedValueOnce(highSecurityDenied());
218
+
219
+ const program = buildProgram();
220
+ try {
221
+ await program.parseAsync([
222
+ "node",
223
+ "hq",
224
+ "secrets",
225
+ "exec",
226
+ "--only",
227
+ "ANTHROPIC_API_KEY",
228
+ "--",
229
+ "env",
230
+ ]);
231
+ } catch {
232
+ // exit sentinel
233
+ }
234
+ const exitCode = exitSpy.mock.calls[0]?.[0] as number | undefined;
235
+
236
+ expect(exitCode).toBe(1);
237
+ const errText = errSpy.mock.calls.flat().join(" ");
238
+ expect(errText).toMatch(/high-security/i);
239
+ expect(errText).toMatch(/proxy/i);
240
+ });
241
+ });
242
+
163
243
  describe("secrets generate-link", () => {
164
244
  it("mints one-time submission links for personal secrets", async () => {
165
245
  const program = buildProgram();
@@ -392,6 +392,13 @@ export async function loadRevealedSecrets(
392
392
  if (!res.ok) {
393
393
  const body = (await res.json().catch(() => ({}))) as Record<string, unknown>;
394
394
  const message = extractApiMessage(body, res.statusText);
395
+ // High-security ("nuclear") refusal surfaced at the batch level (rather
396
+ // than per-name): point the caller at the proxy and never leak plaintext.
397
+ if (body.code === "high_security_denied" || body.highSecurity === true) {
398
+ throw new Error(
399
+ "A requested secret is high-security and cannot be injected locally — it can only be used through the HQ secret proxy, which keeps the plaintext server-side.",
400
+ );
401
+ }
395
402
  if (
396
403
  res.status >= 400 &&
397
404
  res.status < 500 &&
@@ -428,6 +435,17 @@ export async function loadRevealedSecrets(
428
435
  for (const key of chunk) {
429
436
  if (resolved.has(key)) continue;
430
437
  const err = errorsByName.get(key);
438
+ // High-security ("nuclear") secret: the server refuses to vend it on the
439
+ // local-injection (batch-load) path — per-name code `high_security_denied`,
440
+ // no plaintext returned. Every caller of loadRevealedSecrets injects or
441
+ // prints the plaintext locally (`secrets get --reveal`, `secrets exec`,
442
+ // `secrets env`), so a high-security secret can NEVER be used here. Surface
443
+ // a clear, actionable error pointing at the proxy instead of a raw failure.
444
+ if (err?.code === "high_security_denied") {
445
+ throw new Error(
446
+ `Secret '${key}' is high-security and cannot be injected locally — it can only be used through the HQ secret proxy, which keeps the plaintext server-side.`,
447
+ );
448
+ }
431
449
  const reason =
432
450
  err?.code === "not_found"
433
451
  ? "Secret not found"
@@ -538,6 +556,23 @@ export function registerSecretsCommand(program: Command): void {
538
556
 
539
557
  if (!res.ok) {
540
558
  const body = (await res.json().catch(() => ({}))) as Record<string, unknown>;
559
+ // High-security ("nuclear") secret: the server refuses to reveal it on
560
+ // the local-injection path (403, no plaintext). Surface a clear,
561
+ // actionable error pointing the user at the proxy rather than a raw
562
+ // 4xx — the value can ONLY be used through the server-side proxy.
563
+ if (res.status === 403 && body.highSecurity === true) {
564
+ console.error(
565
+ chalk.red(
566
+ `Secret '${name}' is high-security and cannot be revealed locally.`,
567
+ ),
568
+ );
569
+ console.error(
570
+ chalk.dim(
571
+ " It can only be used through the HQ secret proxy, which keeps the plaintext server-side.",
572
+ ),
573
+ );
574
+ process.exit(1);
575
+ }
541
576
  console.error(
542
577
  chalk.red(`Failed to get secret: ${extractApiMessage(body, res.statusText)}`),
543
578
  );
@@ -153,4 +153,43 @@ describe('hq-plugin', () => {
153
153
  const fooErrors = (graph as any).configSchema['FOO'].errors as Array<{ message: string }>;
154
154
  expect(fooErrors.some((e) => e.message.includes('No read permission for secret "FOO"'))).toBe(true);
155
155
  });
156
+
157
+ // US-003 (secrets-server-proxy): the server refuses to batch-load a
158
+ // high-security ("nuclear") secret (code: high_security_denied, no plaintext).
159
+ // `hq run` injects plaintext into the child env, so it must surface this as a
160
+ // clear, actionable error pointing at the proxy — and never resolve a value.
161
+ it('high-security-denied: nuclear secret is surfaced as a proxy-pointing ResolutionError, no value', async () => {
162
+ const schemaPath = path.join(tmpDir, '.env.schema');
163
+ fs.writeFileSync(schemaPath, `# @hqCompany("test")\n\nANTHROPIC_API_KEY=hq()\n`);
164
+
165
+ const uid = `test-uid-${Math.random().toString(36).slice(2)}`;
166
+ const mocks = makeMocks({
167
+ resolveCompanyUid: async () => uid,
168
+ fetchBatch: async () => ({
169
+ secrets: [],
170
+ errors: [
171
+ {
172
+ name: 'ANTHROPIC_API_KEY',
173
+ code: 'high_security_denied',
174
+ message: 'high-security; use the proxy',
175
+ },
176
+ ],
177
+ }),
178
+ });
179
+
180
+ let state!: PluginState;
181
+ const graph = await internal.loadEnvGraph({
182
+ entryFilePaths: [schemaPath],
183
+ afterInit: async (g) => {
184
+ state = installHqPlugin(g, mocks);
185
+ },
186
+ });
187
+ await prewarmHqSecrets(graph, mocks, state);
188
+ await graph.resolveEnvValues();
189
+
190
+ const errs = (graph as any).configSchema['ANTHROPIC_API_KEY'].errors as Array<{ message: string }>;
191
+ expect(errs.some((e) => /high-security/i.test(e.message) && /proxy/i.test(e.message))).toBe(true);
192
+ // No plaintext value was resolved for the nuclear secret.
193
+ expect((graph.getResolvedEnvObject() as Record<string, unknown>).ANTHROPIC_API_KEY).toBeUndefined();
194
+ });
156
195
  });
@@ -85,6 +85,13 @@ export function installHqPlugin(graph: any /* EnvGraph */, opts: InstallHqPlugin
85
85
  if (err.code === 'not_found') {
86
86
  throw new ResolutionError(`Secret "${secretName}" does not exist in company`);
87
87
  }
88
+ // High-security ("nuclear") secret: the server refuses to vend it on
89
+ // the local-injection path. It can ONLY be used through the
90
+ // server-side proxy, so `hq run` (which injects plaintext into the
91
+ // child env) can never load it. Surface a clear, actionable error.
92
+ if (err.code === 'high_security_denied') {
93
+ throw new ResolutionError(`Secret "${secretName}" is high-security and cannot be injected locally — it can only be used via the HQ secret proxy (POST /secrets/{companyUid}/proxy/{path}), which keeps the plaintext server-side. Remove it from this schema's locally-injected vars.`);
94
+ }
88
95
  throw new ResolutionError(`Failed to load secret "${secretName}": ${err.message ?? err.code}`);
89
96
  }
90
97
  // Sentinel-check style throughout: `readCache` returns `string | null`
package/src/sentry.ts CHANGED
@@ -2,13 +2,17 @@ import * as Sentry from "@sentry/node";
2
2
  import { BUNDLED_DSN } from "./sentry-dsn.generated.js";
3
3
  import { beforeSend } from "./sentry-before-send.js";
4
4
  import { beforeBreadcrumb } from "./utils/breadcrumb-buffer.js";
5
+ import { CLI_VERSION } from "./cli-version.js";
5
6
 
6
7
  export function initSentry(): void {
7
8
  const dsn = BUNDLED_DSN || process.env.SENTRY_DSN;
8
9
  if (!dsn) return;
9
10
  Sentry.init({
10
11
  dsn,
11
- release: `hq-cli@${process.env.npm_package_version ?? "0.0.0"}`,
12
+ // CLI_VERSION reads package.json at runtime; npm_package_version is only
13
+ // set under `npm run`, so an installed `hq` binary would always report
14
+ // 0.0.0 and never match the uploaded source maps.
15
+ release: `hq-cli@${CLI_VERSION}`,
12
16
  environment: process.env.HQ_CLI_ENV ?? "production",
13
17
  initialScope: {
14
18
  tags: { repo: "hq-cli" },
@@ -91,6 +91,17 @@ describe("collectDiagnostics", () => {
91
91
  if (saved !== undefined) process.env.npm_package_version = saved;
92
92
  });
93
93
 
94
+ it("attaches a versions block carrying the cli version (core/sync best-effort)", () => {
95
+ const blob = collectDiagnostics();
96
+ expect(blob.versions.cli).toBe(CLI_VERSION);
97
+ // core + sync are environment-dependent; they must be present as
98
+ // string | null, never undefined, so triage always gets the shape.
99
+ expect(blob.versions).toHaveProperty("core");
100
+ expect(blob.versions).toHaveProperty("sync");
101
+ expect(["string", "object"]).toContain(typeof blob.versions.core); // string | null
102
+ expect(["string", "object"]).toContain(typeof blob.versions.sync); // string | null
103
+ });
104
+
94
105
  it("cliVersion is unaffected by npm_package_version env var", () => {
95
106
  process.env.npm_package_version = "99.99.99";
96
107
  const blob = collectDiagnostics();
@@ -2,6 +2,7 @@ import * as os from "os";
2
2
  import { execFileSync } from "child_process";
3
3
  import { getRecentBreadcrumbs } from "./breadcrumb-buffer.js";
4
4
  import { CLI_VERSION } from "../cli-version.js";
5
+ import { collectVersions, type VersionInfo } from "./feedback-versions.js";
5
6
 
6
7
  export interface GitContext {
7
8
  branch: string | null;
@@ -12,6 +13,12 @@ export interface GitContext {
12
13
 
13
14
  export interface DiagnosticsBlob {
14
15
  cliVersion: string;
16
+ /**
17
+ * The hq-cli, hq-core, and hq-sync versions from the submitter's
18
+ * environment. `cliVersion` above is retained for back-compat; new
19
+ * consumers should read `versions` (which carries core + sync too).
20
+ */
21
+ versions: VersionInfo;
15
22
  nodeVersion: string;
16
23
  os: { platform: string; release: string; arch: string };
17
24
  command: string[];
@@ -101,6 +108,7 @@ function collectGitContext(): GitContext {
101
108
  export function collectDiagnostics(): DiagnosticsBlob {
102
109
  return {
103
110
  cliVersion: CLI_VERSION,
111
+ versions: collectVersions(),
104
112
  nodeVersion: process.version,
105
113
  os: {
106
114
  platform: os.platform(),
@@ -0,0 +1,134 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import * as fs from "node:fs";
3
+ import * as os from "node:os";
4
+ import * as path from "node:path";
5
+
6
+ vi.mock("./vault-api.js", () => ({
7
+ vaultApiFetch: vi.fn(),
8
+ }));
9
+
10
+ import { vaultApiFetch } from "./vault-api.js";
11
+ import {
12
+ MAX_SCREENSHOTS,
13
+ contentTypeForPath,
14
+ loadScreenshots,
15
+ uploadScreenshots,
16
+ } from "./feedback-screenshots.js";
17
+
18
+ let tmp: string;
19
+
20
+ beforeEach(() => {
21
+ tmp = fs.mkdtempSync(path.join(os.tmpdir(), "shots-"));
22
+ vi.mocked(vaultApiFetch).mockReset();
23
+ });
24
+ afterEach(() => {
25
+ fs.rmSync(tmp, { recursive: true, force: true });
26
+ });
27
+
28
+ function writePng(name: string, bytes = Buffer.from([0x89, 0x50, 0x4e, 0x47])): string {
29
+ const p = path.join(tmp, name);
30
+ fs.writeFileSync(p, bytes);
31
+ return p;
32
+ }
33
+
34
+ describe("contentTypeForPath", () => {
35
+ it("maps known image extensions (case-insensitive)", () => {
36
+ expect(contentTypeForPath("a.png")).toBe("image/png");
37
+ expect(contentTypeForPath("a.JPG")).toBe("image/jpeg");
38
+ expect(contentTypeForPath("a.jpeg")).toBe("image/jpeg");
39
+ expect(contentTypeForPath("a.webp")).toBe("image/webp");
40
+ expect(contentTypeForPath("a.gif")).toBe("image/gif");
41
+ });
42
+ it("rejects unsupported extensions", () => {
43
+ expect(() => contentTypeForPath("a.pdf")).toThrow(/unsupported screenshot type/);
44
+ expect(() => contentTypeForPath("a")).toThrow(/unsupported screenshot type/);
45
+ });
46
+ });
47
+
48
+ describe("loadScreenshots", () => {
49
+ it("reads valid images with their content types", () => {
50
+ const a = writePng("a.png");
51
+ const loaded = loadScreenshots([a]);
52
+ expect(loaded).toHaveLength(1);
53
+ expect(loaded[0].contentType).toBe("image/png");
54
+ expect(loaded[0].bytes.byteLength).toBeGreaterThan(0);
55
+ });
56
+ it("rejects more than MAX_SCREENSHOTS", () => {
57
+ const paths = Array.from({ length: MAX_SCREENSHOTS + 1 }, (_, i) => writePng(`s${i}.png`));
58
+ expect(() => loadScreenshots(paths)).toThrow(new RegExp(`at most ${MAX_SCREENSHOTS}`));
59
+ });
60
+ it("rejects a missing file", () => {
61
+ expect(() => loadScreenshots([path.join(tmp, "nope.png")])).toThrow(/cannot read screenshot/);
62
+ });
63
+ it("rejects an empty file", () => {
64
+ expect(() => loadScreenshots([writePng("empty.png", Buffer.alloc(0))])).toThrow(/empty/);
65
+ });
66
+ });
67
+
68
+ describe("uploadScreenshots", () => {
69
+ it("returns [] without calling the API when no paths given", async () => {
70
+ const keys = await uploadScreenshots({ paths: [], token: "t" });
71
+ expect(keys).toEqual([]);
72
+ expect(vaultApiFetch).not.toHaveBeenCalled();
73
+ });
74
+
75
+ it("presigns, PUTs each image to S3, and returns the keys", async () => {
76
+ const a = writePng("a.png");
77
+ const b = writePng("b.jpg");
78
+ vi.mocked(vaultApiFetch).mockResolvedValue({
79
+ ok: true,
80
+ json: async () => ({
81
+ screenshots: [
82
+ { key: "feedback-screenshots/prs_a/sub/0.png", url: "https://s3/put0", contentType: "image/png" },
83
+ { key: "feedback-screenshots/prs_a/sub/1.jpg", url: "https://s3/put1", contentType: "image/jpeg" },
84
+ ],
85
+ }),
86
+ } as unknown as Response);
87
+ const putCalls: Array<{ url: string; method?: string; contentType?: unknown }> = [];
88
+ const fetchImpl = vi.fn(async (url: string, init: { method?: string; headers?: Record<string, string> }) => {
89
+ putCalls.push({ url, method: init.method, contentType: init.headers?.["Content-Type"] });
90
+ return { ok: true, status: 200 } as Response;
91
+ }) as unknown as typeof fetch;
92
+
93
+ const keys = await uploadScreenshots({ paths: [a, b], token: "tok", fetchImpl });
94
+
95
+ expect(keys).toEqual([
96
+ "feedback-screenshots/prs_a/sub/0.png",
97
+ "feedback-screenshots/prs_a/sub/1.jpg",
98
+ ]);
99
+ // Presign request asked for the right content types.
100
+ const presignBody = vi.mocked(vaultApiFetch).mock.calls[0][0].body;
101
+ expect(presignBody).toEqual({ contentTypes: ["image/png", "image/jpeg"] });
102
+ // One PUT per image, to the presigned URL, with the right Content-Type.
103
+ expect(putCalls).toEqual([
104
+ { url: "https://s3/put0", method: "PUT", contentType: "image/png" },
105
+ { url: "https://s3/put1", method: "PUT", contentType: "image/jpeg" },
106
+ ]);
107
+ });
108
+
109
+ it("throws when the presign call fails", async () => {
110
+ const a = writePng("a.png");
111
+ vi.mocked(vaultApiFetch).mockResolvedValue({
112
+ ok: false,
113
+ statusText: "Bad Request",
114
+ json: async () => ({ error: "at most 5 screenshots are allowed" }),
115
+ } as unknown as Response);
116
+ await expect(uploadScreenshots({ paths: [a], token: "t" })).rejects.toThrow(
117
+ /Failed to presign screenshots: at most 5/,
118
+ );
119
+ });
120
+
121
+ it("throws when an S3 upload fails", async () => {
122
+ const a = writePng("a.png");
123
+ vi.mocked(vaultApiFetch).mockResolvedValue({
124
+ ok: true,
125
+ json: async () => ({
126
+ screenshots: [{ key: "feedback-screenshots/prs_a/sub/0.png", url: "https://s3/put0", contentType: "image/png" }],
127
+ }),
128
+ } as unknown as Response);
129
+ const fetchImpl = vi.fn(async () => ({ ok: false, status: 403 }) as Response) as unknown as typeof fetch;
130
+ await expect(uploadScreenshots({ paths: [a], token: "t", fetchImpl })).rejects.toThrow(
131
+ /Failed to upload screenshot .*: HTTP 403/,
132
+ );
133
+ });
134
+ });