diff --git a/src/clients/custom-types.ts b/src/clients/custom-types.ts index f26436a..f6bd316 100644 --- a/src/clients/custom-types.ts +++ b/src/clients/custom-types.ts @@ -168,7 +168,7 @@ export async function removeSlice( }); } -const AclCreateResponseSchema = z.object({ +const ScreenshotPresignedUrlResponseSchema = z.object({ values: z.object({ url: z.string(), fields: z.record(z.string(), z.string()), @@ -183,6 +183,20 @@ const SUPPORTED_IMAGE_MIME_TYPES: Record = { "image/webp": ".webp", }; +export async function deleteScreenshots( + sliceId: string, + config: { repo: string; token: string | undefined; host: string }, +): Promise { + const { repo, token, host } = config; + const url = new URL("delete", getScreenshotServiceUrl(host)); + url.searchParams.set("repository", repo); + await request(url, { + method: "POST", + headers: { repository: repo, Authorization: `Bearer ${token}` }, + body: { sliceId }, + }); +} + export async function uploadScreenshot( blob: Blob, config: { @@ -200,29 +214,30 @@ export async function uploadScreenshot( throw new UnsupportedFileTypeError(type); } - const aclUrl = new URL("create", getAclProviderUrl(host)); - const acl = await request(aclUrl, { - headers: { Repository: repo, Authorization: `Bearer ${token}` }, - schema: AclCreateResponseSchema, + const presignedUrl = new URL("presigned-url", getScreenshotServiceUrl(host)); + presignedUrl.searchParams.set("repository", repo); + const presigned = await request(presignedUrl, { + headers: { repository: repo, Authorization: `Bearer ${token}` }, + schema: ScreenshotPresignedUrlResponseSchema, }); const extension = SUPPORTED_IMAGE_MIME_TYPES[type]; - const digest = createHash("md5") + const digest = createHash("sha1") .update(new Uint8Array(await blob.arrayBuffer())) .digest("hex"); const key = `${repo}/shared-slices/${sliceId}/${variationId}/${digest}${extension}`; const formData = new FormData(); - for (const [field, value] of Object.entries(acl.values.fields)) { + for (const [field, value] of Object.entries(presigned.values.fields)) { formData.append(field, value); } - formData.append("key", key); - formData.append("Content-Type", type); - formData.append("file", blob); + formData.set("key", key); + formData.set("Content-Type", type); + formData.set("file", blob); - await request(acl.values.url, { method: "POST", body: formData }); + await request(presigned.values.url, { method: "POST", body: formData }); - const url = new URL(key, appendTrailingSlash(acl.imgixEndpoint)); + const url = new URL(key, appendTrailingSlash(presigned.imgixEndpoint)); url.searchParams.set("auto", "compress,format"); return url; @@ -243,6 +258,6 @@ function getCustomTypesServiceUrl(host: string): URL { return new URL(`https://customtypes.${host}/`); } -function getAclProviderUrl(host: string): URL { - return new URL(`https://acl-provider.${host}/`); +function getScreenshotServiceUrl(host: string): URL { + return new URL(`https://api.internal.${host}/screenshot/`); } diff --git a/src/commands/push.ts b/src/commands/push.ts index 7fb3871..1d204f5 100644 --- a/src/commands/push.ts +++ b/src/commands/push.ts @@ -6,6 +6,7 @@ import { getAdapter } from "../adapters"; import { getHost, getToken } from "../auth"; import { getDocumentTotalByCustomTypes } from "../clients/core"; import { + deleteScreenshots, getCustomTypes, getSlices, insertCustomType, @@ -150,6 +151,10 @@ export default createCommand(config, async ({ values }) => { } for (const id of sliceOps.delete.map((m) => m.id)) { await removeSlice(id, { repo, token, host }); + await deleteScreenshots(id, { repo, token, host }).catch((error) => { + const message = error instanceof Error ? error.message : String(error); + console.warn(`Failed to delete screenshots for slice "${id}": ${message}`); + }); } const onboardingSteps: OnboardingStep[] = []; diff --git a/test/fixtures/slice-screenshot.png b/test/fixtures/slice-screenshot.png new file mode 100644 index 0000000..3ddc1f3 Binary files /dev/null and b/test/fixtures/slice-screenshot.png differ diff --git a/test/prismic.ts b/test/prismic.ts index 290a2d1..7801126 100644 --- a/test/prismic.ts +++ b/test/prismic.ts @@ -131,6 +131,22 @@ export async function deleteSlice(sliceId: string, config: RepoConfig): Promise< if (!res.ok) throw new Error(`Failed to delete slice: ${res.status} ${await res.text()}`); } +export async function listScreenshotFiles(config: RepoConfig): Promise { + const host = config.host ?? DEFAULT_HOST; + const url = new URL("files", `https://api.internal.${host}/screenshot/`); + url.searchParams.set("repository", config.repo); + const res = await fetch(url, { + headers: { Authorization: `Bearer ${config.token}` }, + }); + if (!res.ok) throw new Error(`Failed to list screenshot files: ${res.status} ${await res.text()}`); + const data = (await res.json()) as { keys: string[] }; + return data.keys; +} + +export function getScreenshotPrefix(config: RepoConfig, sliceId: string): string { + return `${config.repo}/shared-slices/${sliceId}/`; +} + export async function getWebhooks( config: RepoConfig, ): Promise<{ config: Record }[]> { diff --git a/test/push.serial.test.ts b/test/push.serial.test.ts index 2e18eae..5ed8ace 100644 --- a/test/push.serial.test.ts +++ b/test/push.serial.test.ts @@ -1,5 +1,13 @@ -import { buildCustomType, it, writeLocalCustomType } from "./it"; -import { getCustomTypes, insertCustomType } from "./prismic"; +import { fileURLToPath } from "node:url"; + +import { buildCustomType, buildSlice, it, writeLocalCustomType, writeLocalSlice } from "./it"; +import { + getCustomTypes, + getScreenshotPrefix, + getSlices, + insertCustomType, + listScreenshotFiles, +} from "./prismic"; it("supports --help", async ({ expect, prismic }) => { const { stdout, exitCode } = await prismic("push", ["--help"]); @@ -59,3 +67,64 @@ it("pushes a local edit that overwrites a remote model", async ({ const updated = remote.find((t) => t.id === customType.id); expect(updated?.label).toBe("Modified"); }); + +it("deletes a remote slice and its screenshots when removed locally", async ({ + expect, + project, + prismic, + repo, + token, + host, +}) => { + // Mirror remote into local so push only deletes the slice we remove below. + const pull = await prismic("pull", ["--repo", repo, "--force"]); + expect(pull.exitCode).toBe(0); + + const slice = buildSlice(); + await writeLocalSlice(project, slice); + + const insert = await prismic("push", ["--repo", repo]); + expect(insert.exitCode).toBe(0); + + const screenshotPath = fileURLToPath(new URL("./fixtures/slice-screenshot.png", import.meta.url)); + + const editVariation = await prismic("slice", [ + "edit-variation", + "default", + "--from-slice", + slice.id, + "--screenshot", + screenshotPath, + ]); + expect(editVariation.exitCode).toBe(0); + + const update = await prismic("push", ["--repo", repo]); + expect(update.exitCode).toBe(0); + + const screenshotPrefix = getScreenshotPrefix({ repo, token, host }, slice.id); + await expect + .poll(async () => { + const keys = await listScreenshotFiles({ repo, token, host }); + return keys.some((key) => key.startsWith(screenshotPrefix)); + }) + .toBe(true); + + const remove = await prismic("slice", ["remove", slice.id]); + expect(remove.exitCode).toBe(0); + + const pushDelete = await prismic("push", ["--repo", repo, "--force"]); + expect(pushDelete.exitCode).toBe(0); + + await expect + .poll(async () => (await getSlices({ repo, token, host })).map((s) => s.id), { + timeout: 5_000, + }) + .not.toContain(slice.id); + + await expect + .poll(async () => { + const keys = await listScreenshotFiles({ repo, token, host }); + return keys.some((key) => key.startsWith(screenshotPrefix)); + }) + .toBe(false); +});