-
Notifications
You must be signed in to change notification settings - Fork 87
feat(feedback): port the feedback command to the refactor architecture #2149
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: refactor
Are you sure you want to change the base?
Changes from all commits
6dbebaf
2d80c45
0ecd641
04e24ea
8f53b7b
b980b90
311de1b
6d051c0
c25a04c
bd6bbbc
8f475db
b8c535a
0290388
fa466da
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| { | ||
| "success": true, | ||
| "id": "9385c2bc-e013-4c31-a5c4-e430f221037a", | ||
| "timestamp": "2026-08-31T20:04:07.165808755Z", | ||
| "reference": "agentcore-cli" | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| { | ||
| "status": 200, | ||
| "statusText": "OK", | ||
| "body": "" | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| { | ||
| "status": 200, | ||
| "statusText": "OK", | ||
| "body": "{\"reference\":\"agentcore-cli\",\"id\":\"9385c2bc-e013-4c31-a5c4-e430f221037a\",\"timestamp\":\"2026-08-31T20:04:07.165808755Z\"}" | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| { | ||
| "status": 200, | ||
| "statusText": "OK", | ||
| "body": "https://aperture-forms-uploaded-files-prod-us-east-1.s3.amazonaws.com/us-east-1/AgentCore/CLI/0.1.0/31082026/3066c98d-4cda-42d5-ad1e-b00e8f69b96f.png" | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| { | ||
| "success": true, | ||
| "id": "395e1470-9d77-40a8-af81-9b1181bca976", | ||
| "timestamp": "2026-08-31T20:03:36.685002013Z", | ||
| "reference": "agentcore-cli" | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| { | ||
| "status": 200, | ||
| "statusText": "OK", | ||
| "body": "{\"reference\":\"agentcore-cli\",\"id\":\"395e1470-9d77-40a8-af81-9b1181bca976\",\"timestamp\":\"2026-08-31T20:03:36.685002013Z\"}" | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,178 @@ | ||
| import { describe, expect, test } from "bun:test"; | ||
| import { join } from "node:path"; | ||
| import { CoreClient } from "../../core"; | ||
| import { createRootHandler } from "../index"; | ||
| import { | ||
| createSilentLogger, | ||
| fixtureFactories, | ||
| fixtureFetch, | ||
| matchGolden, | ||
| TestGlobalConfigAccessor, | ||
| testIO, | ||
| type TestIOOptions, | ||
| } from "../../testing"; | ||
| import { UserCancellationError } from "../../errors"; | ||
| import { ApertureError } from "./submit"; | ||
| import type { CoreFetch } from "../../core/types"; | ||
|
|
||
| const REGION = "us-east-1"; | ||
| const FIXTURES = join(import.meta.dir, "__fixtures__"); | ||
| const SHOT = join(FIXTURES, "shot.png"); | ||
|
|
||
| const neverFetch = (async () => { | ||
| throw new Error("network should not be reached"); | ||
| }) as unknown as CoreFetch; | ||
|
|
||
| async function run( | ||
| args: string[], | ||
| opts: { fetch?: CoreFetch; io?: TestIOOptions } = {}, | ||
| ): Promise<{ stdout: string; stderr: string }> { | ||
| const io = testIO(opts.io); | ||
| const core = new CoreClient({ | ||
| ...fixtureFactories(FIXTURES), | ||
| logger: createSilentLogger(), | ||
| fetch: opts.fetch ?? neverFetch, | ||
| }); | ||
| const root = createRootHandler(core, { | ||
| io: io.io, | ||
| logger: createSilentLogger(), | ||
| globalConfigAccessor: new TestGlobalConfigAccessor(), | ||
| }); | ||
| await root.route(["node", "agentcore", "feedback", ...args, "--region", REGION]); | ||
| return { stdout: io.stdout(), stderr: io.stderr() }; | ||
| } | ||
|
|
||
| describe("feedback (fixture-backed)", () => { | ||
| test("submits text-only feedback and prints the result envelope", async () => { | ||
| const { stdout } = await run( | ||
| ["[agentcore-cli golden fixture] text submit — please ignore", "--yes", "--json"], | ||
| { fetch: fixtureFetch(join(FIXTURES, "submit-text")) }, | ||
| ); | ||
|
|
||
| matchGolden(FIXTURES, "submit-text.golden.json", stdout); | ||
| const result = JSON.parse(stdout); | ||
| expect(result.success).toBe(true); | ||
| expect(typeof result.id).toBe("string"); | ||
| expect(result.reference).toBe("agentcore-cli"); | ||
| }, 120_000); | ||
|
|
||
| test("submits feedback with a screenshot (presign → S3 PUT → form)", async () => { | ||
| const { stdout } = await run( | ||
| [ | ||
| "[agentcore-cli golden fixture] screenshot submit — please ignore", | ||
| "--screenshot", | ||
| SHOT, | ||
| "--yes", | ||
| "--json", | ||
| ], | ||
| { fetch: fixtureFetch(join(FIXTURES, "submit-screenshot")) }, | ||
| ); | ||
|
|
||
| matchGolden(FIXTURES, "submit-screenshot.golden.json", stdout); | ||
| expect(JSON.parse(stdout).success).toBe(true); | ||
| }, 120_000); | ||
|
|
||
| test("without --yes and without a TTY it fails rather than submitting", async () => { | ||
| await expect(run(["headless", "--json"], { fetch: neverFetch })).rejects.toThrow(/--yes/); | ||
| }); | ||
|
|
||
| test("declining the consent prompt cancels", async () => { | ||
| await expect( | ||
| run(["no thanks"], { fetch: neverFetch, io: { isTTY: true, stdin: "n\n" } }), | ||
| ).rejects.toBeInstanceOf(UserCancellationError); | ||
| }); | ||
|
|
||
| test("an empty message is rejected", async () => { | ||
| await expect(run([" ", "--yes"], { fetch: neverFetch })).rejects.toThrow(/cannot be empty/); | ||
| }); | ||
|
|
||
| test("a message over 1000 characters is rejected", async () => { | ||
| await expect(run(["x".repeat(1001), "--yes"], { fetch: neverFetch })).rejects.toThrow( | ||
| /1000 characters/, | ||
| ); | ||
| }); | ||
|
|
||
| test("an explicitly-empty --screenshot is rejected", async () => { | ||
| await expect(run(["msg", "--screenshot", "", "--yes"], { fetch: neverFetch })).rejects.toThrow( | ||
| /--screenshot requires a file path/, | ||
| ); | ||
| }); | ||
| }); | ||
|
|
||
| type Recorded = { url: string; method: string; headers: Headers; body: unknown }; | ||
|
|
||
| function capturingFetch(canned: { presign?: string; form?: string; formStatus?: number }): { | ||
| fetch: CoreFetch; | ||
| calls: Recorded[]; | ||
| } { | ||
| const calls: Recorded[] = []; | ||
| const fetch = (async (input: Parameters<CoreFetch>[0], init?: Parameters<CoreFetch>[1]) => { | ||
| const url = String(input); | ||
| calls.push({ | ||
| url, | ||
| method: init?.method ?? "GET", | ||
| headers: new Headers(init?.headers), | ||
| body: init?.body, | ||
| }); | ||
| if (url.includes("/presignedurl")) { | ||
| return new Response(canned.presign ?? "", { status: 200 }); | ||
| } | ||
| if (url.includes("/form")) { | ||
| return new Response(canned.form ?? "{}", { | ||
| status: canned.formStatus ?? 200, | ||
| headers: { "content-type": "application/json" }, | ||
| }); | ||
| } | ||
| return new Response(null, { status: 200 }); | ||
| }) as unknown as CoreFetch; | ||
| return { fetch, calls }; | ||
| } | ||
|
|
||
| const OK_FORM = JSON.stringify({ | ||
| reference: "agentcore-cli", | ||
| id: "id-1", | ||
| timestamp: "2026-01-01T00:00:00Z", | ||
| }); | ||
| const PRESIGN_URL = | ||
| "https://bucket.s3.us-east-1.amazonaws.com/us-east-1/AgentCore/CLI/0.1.0/13052026/abc-123.png?X-Amz-Signature=sig"; | ||
|
|
||
| describe("feedback (request contract)", () => { | ||
| test("screenshot flow sends checksum + NOT_SCANNED headers and the parsed object key", async () => { | ||
| const { fetch, calls } = capturingFetch({ presign: PRESIGN_URL, form: OK_FORM }); | ||
| await run(["with shot", "--screenshot", SHOT, "--yes", "--json"], { fetch }); | ||
|
|
||
| expect( | ||
| calls.map((c) => | ||
| c.url.includes("/presignedurl") ? "presign" : c.url.includes("/form") ? "form" : "s3", | ||
| ), | ||
| ).toEqual(["presign", "s3", "form"]); | ||
|
|
||
| const put = calls[1]!; | ||
| expect(put.method).toBe("PUT"); | ||
| expect(put.headers.get("x-amz-checksum-algorithm")).toBe("SHA256"); | ||
| expect(put.headers.get("x-amz-checksum-sha256")).toBeTruthy(); | ||
| expect(put.headers.get("x-amz-tagging")).toBe("scanstatus=NOT_SCANNED"); | ||
|
|
||
| const form = JSON.parse(String(calls[2]!.body)); | ||
| const attachment = form.customerResponses.find( | ||
| (r: { response: { responseType: string } }) => r.response.responseType === "fileUpload", | ||
| ); | ||
| expect(attachment.response.responseValue).toEqual([ | ||
| "us-east-1/AgentCore/CLI/0.1.0/13052026/abc-123.png", | ||
| ]); | ||
| }); | ||
|
|
||
| test("a malformed form response is rejected as an ApertureError", async () => { | ||
| const { fetch } = capturingFetch({ form: "{}" }); | ||
| await expect(run(["hi", "--yes", "--json"], { fetch })).rejects.toBeInstanceOf(ApertureError); | ||
| }); | ||
|
|
||
| test("an invalid presign body fails before the upload (no PUT)", async () => { | ||
| const { fetch, calls } = capturingFetch({ presign: "not-a-url", form: OK_FORM }); | ||
| await expect( | ||
| run(["with shot", "--screenshot", SHOT, "--yes", "--json"], { fetch }), | ||
| ).rejects.toBeInstanceOf(ApertureError); | ||
| expect(calls).toHaveLength(1); | ||
| expect(calls[0]!.url).toContain("/presignedurl"); | ||
| }); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,77 @@ | ||
| import { createInterface } from "node:readline/promises"; | ||
| import z from "zod"; | ||
| import { argument, createHandler, flag } from "../../router"; | ||
| import { JsonRendererKey } from "../../tui"; | ||
| import { JsonKey } from "../keys.tsx"; | ||
| import { InputValidationError, UserCancellationError } from "../../errors"; | ||
| import { CONSENT_TEXT, submitFeedback } from "./submit"; | ||
| import type { AppIO } from "../../io"; | ||
| import type { Core } from "../types.tsx"; | ||
|
|
||
| export const createFeedbackHandler = (core: Core, io: AppIO) => | ||
| createHandler({ | ||
| name: "feedback", | ||
| description: "Send feedback about the AgentCore CLI to the team.", | ||
| arguments: [argument("message", "the feedback message to send", z.string())], | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is the no-argument feedback wizard intentionally out of scope? Released
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Let's not increase scope. Leaving out the TUI makes sense here. |
||
| flags: [ | ||
| flag( | ||
| "screenshot", | ||
| "path to a PNG or JPG screenshot to attach (max 100MB)", | ||
| z.string().optional(), | ||
| ), | ||
| flag( | ||
| "yes", | ||
| "accept the AWS Customer Agreement and skip the consent prompt", | ||
| z.boolean().default(false), | ||
| ), | ||
| ], | ||
| handle: async (ctx, flags, args) => { | ||
| const screenshotPath = flags["screenshot"]; | ||
| if (screenshotPath !== undefined && screenshotPath.trim() === "") { | ||
| throw new InputValidationError("--screenshot requires a file path"); | ||
| } | ||
|
|
||
| await confirmConsent(io, ctx.require(JsonKey), flags.yes); | ||
|
|
||
| const result = await submitFeedback( | ||
| { | ||
| message: args["message"], | ||
| screenshot: screenshotPath ? { path: screenshotPath } : undefined, | ||
| }, | ||
| core.fetch, | ||
| ); | ||
|
|
||
| ctx.require(JsonRendererKey).renderJson({ success: true, ...result }); | ||
| }, | ||
| }); | ||
|
|
||
| async function confirmConsent(io: AppIO, jsonOutput: boolean, confirmed: boolean): Promise<void> { | ||
| if (confirmed) return; | ||
| const canPrompt = !jsonOutput && io.stdin.isTTY && io.stdout.isTTY && io.stderr.isTTY; | ||
| if (!canPrompt) { | ||
| throw new InputValidationError( | ||
| "submitting feedback requires accepting the AWS Customer Agreement; re-run with --yes to confirm non-interactively", | ||
| ); | ||
| } | ||
| if (!(await promptForConsent(io))) { | ||
| throw new UserCancellationError(); | ||
| } | ||
| } | ||
|
|
||
| async function promptForConsent(io: AppIO): Promise<boolean> { | ||
| const readline = createInterface({ input: io.stdin, output: io.stderr }); | ||
| try { | ||
| const cancelled = new Promise<never>((_resolve, reject) => { | ||
| const cancel = () => reject(new UserCancellationError()); | ||
| readline.once("SIGINT", cancel); | ||
| readline.once("close", cancel); | ||
| }); | ||
| const answer = await Promise.race([ | ||
| readline.question(`\n${CONSENT_TEXT}\n\nSubmit feedback? (y/N) `), | ||
| cancelled, | ||
| ]); | ||
| return /^(?:y|yes)$/i.test(answer.trim()); | ||
| } finally { | ||
| readline.close(); | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I do not think this golden test proves the request contract in its name.
fixtureFetchkeys only on method/path and ignores request headers and bodies; I replayed the PUT with no checksum/tagging headers and the form POST with{}, and both returned 200. I think we should keep the golden flow but restore a focused injected-fetch test for the checksum headers,scanstatus=NOT_SCANNED, and attachment object key.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sure, I'll add a mock test for this.