Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 29 additions & 14 deletions src/clients/custom-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()),
Expand All @@ -183,6 +183,20 @@ const SUPPORTED_IMAGE_MIME_TYPES: Record<string, string> = {
"image/webp": ".webp",
};

export async function deleteScreenshots(
sliceId: string,
config: { repo: string; token: string | undefined; host: string },
): Promise<void> {
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: {
Expand All @@ -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")
Copy link
Copy Markdown
Contributor Author

@jomifepe jomifepe May 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is to match Type Builder (md5 is not native to web), not critical, but not harmful.

.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;
Expand All @@ -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/`);
}
5 changes: 5 additions & 0 deletions src/commands/push.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { getAdapter } from "../adapters";
import { getHost, getToken } from "../auth";
import { getDocumentTotalByCustomTypes } from "../clients/core";
import {
deleteScreenshots,
getCustomTypes,
getSlices,
insertCustomType,
Expand Down Expand Up @@ -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[] = [];
Expand Down
Binary file added test/fixtures/slice-screenshot.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
16 changes: 16 additions & 0 deletions test/prismic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string[]> {
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<string, unknown> }[]> {
Expand Down
73 changes: 71 additions & 2 deletions test/push.serial.test.ts
Original file line number Diff line number Diff line change
@@ -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"]);
Expand Down Expand Up @@ -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);
});
Loading