From 53d183a263621bbd26a47e7d7afc315e5467a030 Mon Sep 17 00:00:00 2001 From: gimenes Date: Thu, 8 Oct 2026 15:16:03 -0300 Subject: [PATCH 1/2] test(e2e): content protocol, drafts and publish over GitHub Adds the end-to-end spec for the draft, publish and release loop on the GitHub backend; the GitHub stub gains git blob ids, sha compare and the RepoCommits GraphQL query, and fast-preview projects can carry a siteSlug. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WNwbSEePYNcY5YCgqZURig --- packages/e2e/fixtures/fast-preview.ts | 5 + packages/e2e/fixtures/github-stub.ts | 78 +- .../e2e/tests/content-protocol-github.spec.ts | 830 ++++++++++++++++++ 3 files changed, 909 insertions(+), 4 deletions(-) create mode 100644 packages/e2e/tests/content-protocol-github.spec.ts diff --git a/packages/e2e/fixtures/fast-preview.ts b/packages/e2e/fixtures/fast-preview.ts index 66ee1ef270..c390618ae8 100644 --- a/packages/e2e/fixtures/fast-preview.ts +++ b/packages/e2e/fixtures/fast-preview.ts @@ -98,6 +98,8 @@ export async function createFastPreviewProject( */ connectionUrl?: string; previewServerUrl?: string; + /** The public site id (`metadata.siteSlug`) the hosted Deco CMS keys on. */ + siteSlug?: string; }, ): Promise { const { @@ -162,6 +164,7 @@ export async function createFastPreviewProject( title: `${repo} ${Date.now()}`, metadata: { fastPreview: true, + ...(params.siteSlug ? { siteSlug: params.siteSlug } : {}), previewServerUrl: params.previewServerUrl ?? `https://${repo}.example.com`, repository: { @@ -178,6 +181,8 @@ export async function createFastPreviewProject( ); const vmcpId = vmcp.item.id; expect(vmcpId).toBeTruthy(); + // Hosted features need the project linked to its site in `org_sites`: + // creating the project linked its `siteSlug` (hosted/claim-site.ts). return { org, owner, repo, vmcpId, childConnectionId }; } diff --git a/packages/e2e/fixtures/github-stub.ts b/packages/e2e/fixtures/github-stub.ts index 5989b10e6c..de052622db 100644 --- a/packages/e2e/fixtures/github-stub.ts +++ b/packages/e2e/fixtures/github-stub.ts @@ -32,7 +32,7 @@ * POST /repos/{o}/{r}/merges -> 201 {sha} / 204 / 409 / 405 * GET /repos/{o}/{r}/pulls?state&base&head -> [ { number, html_url } ] * POST /repos/{o}/{r}/pulls -> { number, html_url } - * GET /repos/{o}/{r}/compare/{base}...{head} -> { ahead_by, behind_by, merge_base_commit, files, commits } + * GET /repos/{o}/{r}/compare/{base}...{head} -> { ahead_by, behind_by, merge_base_commit, files, commits } (branch or sha) * GET /repos/{o}/{r}/commits -> [ { sha } ] (default-branch head) * POST /repos/{o}/{r}/generate -> 201 new repo from this template (422 name taken) * @@ -49,6 +49,7 @@ * * Test-only admin endpoints (no auth): * GET /health + * POST /graphql RepoCommits (commit history) only * POST /__admin/repos seed a repo (see SeedRepoBody) * GET /__admin/repos/{o}/{r} inspect refs/commits/files per branch * POST /__admin/repos/{o}/{r}/config { mergeMode } — flip merge behavior @@ -158,8 +159,13 @@ function repoKey(owner: string, name: string): string { return `${owner}/${name}`; } +/** Git's own blob id, so callers that hash content locally agree with the stub. */ function putBlob(repo: RepoState, content: string): string { - const sha = sha1(`blob:${content}`); + const bytes = Buffer.from(content, "utf-8"); + const sha = createHash("sha1") + .update(`blob ${bytes.length}\0`) + .update(bytes) + .digest("hex"); repo.blobs.set(sha, content); return sha; } @@ -324,6 +330,61 @@ function readBody(req: IncomingMessage): Promise { }); } +/** + * GraphQL, for the one query a spec needs: a ref's commit history (the + * insights client's `RepoCommits`), first-parent from the ref's head. Any + * other query answers a GraphQL error. + */ +async function handleGraphql( + req: IncomingMessage, + res: ServerResponse, +): Promise { + const body = JSON.parse(await readBody(req)) as { + query?: string; + variables?: Record; + }; + const vars = body.variables ?? {}; + const repo = repos.get(repoKey(String(vars.owner), String(vars.name))); + if (!body.query?.includes("query RepoCommits") || !repo) { + json(res, 200, { errors: [{ message: "unsupported by the stub" }] }); + return; + } + const ref = typeof vars.ref === "string" ? vars.ref : repo.defaultBranch; + const shas: string[] = []; + for ( + let sha = repo.refs.get(ref); + sha !== undefined; + sha = repo.commits.get(sha)?.parents[0] + ) { + shas.push(sha); + } + const start = typeof vars.after === "string" ? Number(vars.after) : 0; + const limit = Number(vars.limit ?? 50); + const page = shas.slice(start, start + limit); + const target = { + history: { + pageInfo: { + hasNextPage: start + page.length < shas.length, + endCursor: String(start + page.length), + }, + nodes: page.map((oid) => ({ + oid, + committedDate: new Date().toISOString(), + messageHeadline: (repo.commits.get(oid)?.message ?? "").split("\n")[0], + author: { name: "e2e", email: null, user: null }, + })), + }, + }; + json(res, 200, { + data: { + repository: + typeof vars.ref === "string" + ? { ref: { target } } + : { defaultBranchRef: { target } }, + }, + }); +} + function json(res: ServerResponse, status: number, body?: unknown): void { if (status === 204) { res.writeHead(204); @@ -906,8 +967,13 @@ async function handleRepos( const [rawBase, rawHead] = rawSpec.split("..."); const decodePath = (raw: string | undefined): string => (raw ?? "").split("/").map(decodeURIComponent).join("/"); - const baseSha = repo.refs.get(decodePath(rawBase)); - const headSha = repo.refs.get(decodePath(rawHead)); + // A side is a branch or, as on real GitHub, a commit sha. + const resolve = (raw: string | undefined): string | undefined => { + const name = decodePath(raw); + return repo.refs.get(name) ?? (repo.commits.has(name) ? name : undefined); + }; + const baseSha = resolve(rawBase); + const headSha = resolve(rawHead); if (!baseSha || !headSha) { notFound(res); return; @@ -1184,6 +1250,10 @@ export function createGithubStubServer(): Server { await handleRepos(req, res, url); return; } + if (req.method === "POST" && url.pathname === "/graphql") { + await handleGraphql(req, res); + return; + } notFound(res); }; dispatch().catch((error) => { diff --git a/packages/e2e/tests/content-protocol-github.spec.ts b/packages/e2e/tests/content-protocol-github.spec.ts new file mode 100644 index 0000000000..f695e05644 --- /dev/null +++ b/packages/e2e/tests/content-protocol-github.spec.ts @@ -0,0 +1,830 @@ +/** + * The site editor's GitHub backend for Blocks v8 sites on the hosted Deco + * CMS: the content protocol over main with the project's CDN draft layered + * on, and the publish that commits that draft to main and releases it. + * + * POST /api/:org/decofile/:virtualMcpId/:branch/rpc (session, org flag) + * GET /api/:org/decofile/:virtualMcpId/:branch the draft pointer (session) + * POST /api/:org/decofile/:virtualMcpId/:branch/publish commit + release + * /api/:org/hosted/:virtualMcpId/{releases,releases/current,site-tokens} + * + * The protocol's own black-box conformance suite runs against the endpoint, + * with the repository on the local GitHub stub (see fixtures/fast-preview.ts) + * and the delivery bucket on its stub (fixtures/delivery-stub-server.ts). The + * cases below it cover what's specific to this backend: the flag, auth, + * saves into the CDN draft (never git), v7 secret blocks left in a migrated + * site, the `?__draft=` pointer the site revalidates with ETags, publish and + * releases, site tokens, and the editor's v7/v8 detection: only a committed + * schema with `"blocksMajor": 8` is a v8 site, even with the org flag on. + */ + +import type { APIRequestContext } from "@playwright/test"; +import type { + BlocksApplyResult, + BlocksListResult, + DescribeResult, +} from "@decocms/blocks/protocol"; +import { runConformance } from "@decocms/blocks/protocol/conformance"; +import { publicKeyPemFromDer } from "@decocms/shared/secret-ciphertext"; +import { signUpViaApi } from "../fixtures/auth-api"; +import { + createFastPreviewProject, + type FastPreviewProject, + inspectStubRepo, + seedStubRepo, + uniqueOwner, +} from "../fixtures/fast-preview"; +import { callSelfMcpTool, findOrgId } from "../fixtures/mcp-tools"; +import { startPreviewSite } from "../fixtures/preview-site"; +import { expect, getE2EAppOrigin, newApiContext, test } from "../fixtures/test"; + +const DELIVERY_STUB_ORIGIN = `http://127.0.0.1:${process.env.DELIVERY_STUB_PORT ?? "4104"}`; + +/** The delivery bucket's objects under a prefix, as the stub holds them. */ +async function deliveryObjects( + ctx: APIRequestContext, + prefix: string, +): Promise< + Record +> { + const res = await ctx.get( + `${DELIVERY_STUB_ORIGIN}/__admin/objects?prefix=${encodeURIComponent(prefix)}`, + ); + expect(res.ok()).toBe(true); + return (await res.json()) as Record< + string, + { text: string; etag: string; cacheControl: string | null } + >; +} + +const SECRET_BLOCK = "newsletter"; +const SECRET_FIELD = "apiKey"; + +/** + * A small v8 schema (deco-meta@1, `"blocksMajor": 8` as `deco schema` writes + * it) with a block type that has a Secret field. + */ +const schema = { + blocksMajor: 8, + manifest: { + blocks: { + sections: { + hero: { $ref: "#/definitions/aGVybw==" }, + [SECRET_BLOCK]: { $ref: "#/definitions/bmV3c2xldHRlcg==" }, + }, + }, + }, + schema: { + definitions: { + "aGVybw==": { + type: "object", + properties: { title: { type: "string" } }, + }, + "bmV3c2xldHRlcg==": { + type: "object", + properties: { + listId: { type: "string" }, + [SECRET_FIELD]: { type: "string", format: "secret" }, + }, + }, + }, + }, +}; + +async function generatePublicKeyPem(): Promise { + const pair = (await crypto.subtle.generateKey( + { + name: "RSA-OAEP", + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]), + hash: "SHA-256", + }, + true, + ["encrypt", "decrypt"], + )) as CryptoKeyPair; + return publicKeyPemFromDer( + new Uint8Array(await crypto.subtle.exportKey("spki", pair.publicKey)), + ); +} + +const rpcPath = (p: FastPreviewProject, branch: string) => + `/api/${p.org}/decofile/${p.vmcpId}/${branch}/rpc`; + +/** A fetch that goes through the Playwright context, so it carries the session. */ +function contextFetch(ctx: APIRequestContext) { + return async (request: Request): Promise => { + const body = + request.method === "GET" || request.method === "HEAD" + ? undefined + : Buffer.from(await request.arrayBuffer()); + const res = await ctx.fetch(request.url, { + method: request.method, + headers: Object.fromEntries(request.headers), + data: body, + maxRedirects: 0, + }); + // The context hands back the decoded body; Content-Encoding stays as sent. + const headers = new Headers(res.headers()); + headers.delete("content-length"); + return new Response(new Uint8Array(await res.body()), { + status: res.status(), + headers, + }); + }; +} + +async function rpc( + ctx: APIRequestContext, + path: string, + method: string, + params: unknown = {}, +): Promise<{ status: number; result?: T; error?: { code: number } }> { + const res = await ctx.post(path, { + data: { jsonrpc: "2.0", id: 1, method, params }, + }); + if (res.status() !== 200) return { status: res.status() }; + const body = (await res.json()) as { result?: T; error?: { code: number } }; + return { status: 200, ...body }; +} + +/** The session read, as the v8 editor makes it: its CDN draft pointer. */ +async function draftPointer( + ctx: APIRequestContext, + project: FastPreviewProject, + branch: string, +): Promise<{ draft: string | null; version: string | null }> { + const res = await ctx.get( + `/api/${project.org}/decofile/${project.vmcpId}/${branch}`, + ); + expect(res.status()).toBe(200); + return (await res.json()) as { draft: string | null; version: string | null }; +} + +async function enableContentProtocol( + ctx: APIRequestContext, + orgSlug: string, +): Promise { + await callSelfMcpTool(ctx, orgSlug, "ORGANIZATION_SETTINGS_UPDATE", { + organizationId: await findOrgId(ctx, orgSlug), + flags: { site_editor_content_protocol: true }, + }); +} + +async function setUp( + ctx: APIRequestContext, + files: Record, +): Promise { + const user = await signUpViaApi(ctx); + const owner = uniqueOwner(); + const project = await createFastPreviewProject(ctx, user.orgSlug, { + owner, + repo: "site", + siteSlug: owner, + }); + await seedStubRepo(ctx, { + owner, + repo: "site", + defaultBranch: "main", + branches: { main: { files } }, + }); + return project; +} + +test.describe("content protocol on GitHub", () => { + test("passes the protocol conformance suite", async ({ playwright }) => { + test.setTimeout(10 * 60_000); + const ctx = await newApiContext(playwright); + try { + const publicKey = await generatePublicKeyPem(); + const project = await setUp(ctx, { + ".deco/schema.gen.json": JSON.stringify(schema), + ".deco/secrets.pub": publicKey, + ".deco/blocks/hero-home.json": '{"__resolveType":"hero"}\n', + }); + await enableContentProtocol(ctx, project.org); + + const report = await runConformance({ + endpoint: `${getE2EAppOrigin()}${rpcPath(project, "main")}`, + fetch: contextFetch(ctx), + secretField: { blockType: SECRET_BLOCK, field: SECRET_FIELD }, + secretsPublicKey: publicKey, + }); + const failures = report.outcomes.filter((o) => o.status === "failed"); + expect(failures, JSON.stringify(failures, null, 2)).toEqual([]); + expect(report.passed).toBeGreaterThan(20); + } finally { + await ctx.dispose(); + } + }); + + test("is off without the org flag, and needs a session", async ({ + playwright, + }) => { + const ctx = await newApiContext(playwright); + const anon = await newApiContext(playwright); + try { + const project = await setUp(ctx, { + ".deco/schema.gen.json": JSON.stringify(schema), + }); + const path = rpcPath(project, "main"); + + expect((await rpc(ctx, path, "describe")).status).toBe(404); + + await enableContentProtocol(ctx, project.org); + const described = await rpc(ctx, path, "describe"); + expect(described.result).toMatchObject({ + protocol: "deco-content", + server: { name: "studio-github" }, + kind: "git", + root: ".", + assets: null, + preview: { url: "https://site.example.com" }, + }); + + expect((await rpc(anon, path, "describe")).status).toBe(401); + } finally { + await ctx.dispose(); + await anon.dispose(); + } + }); + + test("saves into the CDN draft, never into git", async ({ playwright }) => { + const ctx = await newApiContext(playwright); + try { + const project = await setUp(ctx, { + ".deco/schema.gen.json": JSON.stringify(schema), + ".deco/blocks/hero-home.json": '{"__resolveType":"hero"}\n', + ".deco/blocks/promo.json": '{"__resolveType":"hero","title":"Old"}\n', + }); + await enableContentProtocol(ctx, project.org); + const path = rpcPath(project, "draft-1"); + const refsBefore = (await inspectStubRepo(ctx, project.owner, "site")) + .refs; + + const before = await rpc(ctx, path, "blocks.list"); + expect(before.result).toMatchObject({ + resolvedRef: "main", + blocks: { "hero-home": { __resolveType: "hero" } }, + }); + // The protocol has no refs: `ref` is an unknown parameter. + expect( + (await rpc(ctx, path, "blocks.list", { ref: "main" })).error?.code, + ).toBe(-32602); + + const hero = { __resolveType: "hero", title: "Hi" }; + const applied = await rpc(ctx, path, "blocks.apply", { + set: { "hero-home": hero }, + delete: ["promo"], + }); + expect(applied.result?.revision).toMatch(/^[0-9a-f]{40}~/); + + // No branch, no commit: git is untouched. + expect((await inspectStubRepo(ctx, project.owner, "site")).refs).toEqual( + refsBefore, + ); + const drafts = await deliveryObjects( + ctx, + `sites/${project.owner}/drafts/`, + ); + const [draft] = Object.values(drafts); + expect(JSON.parse(draft!.text)).toEqual({ + set: { "hero-home": hero }, + delete: ["promo"], + }); + expect(draft!.cacheControl).toBe("no-cache, max-age=0, must-revalidate"); + + const after = await rpc(ctx, path, "blocks.list"); + expect(after.result).toMatchObject({ + revision: applied.result?.revision, + blocks: { "hero-home": hero }, + }); + expect( + (after.result as { blocks?: Record }).blocks, + ).not.toHaveProperty("promo"); + } finally { + await ctx.dispose(); + } + }); + + test("saves the v7 secret loader blocks a migrated site still has", async ({ + playwright, + }) => { + const ctx = await newApiContext(playwright); + try { + const loader = "website/loaders/secret.ts"; + // A v8 schema that still lists the v7 secret loader, which marks its + // encrypted string `format: secret`. + const migratedSchema = { + blocksMajor: 8, + manifest: { + blocks: { + loaders: { [loader]: { $ref: "#/definitions/c2VjcmV0" } }, + apps: { "site/apps/site.ts": { $ref: "#/definitions/c2l0ZQ==" } }, + }, + }, + schema: { + definitions: { + c2VjcmV0: { + type: "object", + properties: { + name: { type: "string" }, + encrypted: { type: "string", format: "secret" }, + }, + }, + "c2l0ZQ==": { + type: "object", + properties: { apiKey: { $ref: "#/definitions/c2VjcmV0" } }, + }, + }, + }, + }; + const project = await setUp(ctx, { + ".deco/schema.gen.json": JSON.stringify(migratedSchema), + ".deco/blocks/site.json": '{"__resolveType":"site/apps/site.ts"}\n', + }); + await enableContentProtocol(ctx, project.org); + + const applied = await rpc( + ctx, + rpcPath(project, "main"), + "blocks.apply", + { + set: { + site: { + __resolveType: "site/apps/site.ts", + apiKey: { + __resolveType: loader, + name: "API_KEY", + encrypted: "0a1b2c3d", + }, + }, + }, + }, + ); + expect(applied.error).toBeUndefined(); + expect(applied.result?.revision).toEqual(expect.any(String)); + } finally { + await ctx.dispose(); + } + }); + + test("never reads or writes a v7 site over /rpc, even with the flag on", async ({ + playwright, + }) => { + const ctx = await newApiContext(playwright); + try { + // The same schema without `"blocksMajor": 8`: a v7 site. + const project = await setUp(ctx, { + ".deco/schema.gen.json": JSON.stringify({ + manifest: schema.manifest, + schema: schema.schema, + }), + ".deco/blocks/hero-home.json": '{"__resolveType":"hero"}\n', + }); + await enableContentProtocol(ctx, project.org); + const path = rpcPath(project, "main"); + const headBefore = (await inspectStubRepo(ctx, project.owner, "site")) + .refs; + + // It reads as schemaless, so the editor stays on the classic one. + const read = await rpc<{ schema?: unknown }>(ctx, path, "schema.get"); + expect(read.error ?? read.result?.schema ?? null).not.toEqual( + expect.objectContaining({ blocksMajor: expect.anything() }), + ); + const applied = await rpc(ctx, path, "blocks.apply", { + set: { "hero-home": { __resolveType: "hero", title: "x" } }, + }); + expect(applied.error?.code).toBe(-32006); + expect((await inspectStubRepo(ctx, project.owner, "site")).refs).toEqual( + headBefore, + ); + } finally { + await ctx.dispose(); + } + }); + + test("the draft pointer names the CDN draft, which the site revalidates", async ({ + playwright, + }) => { + const ctx = await newApiContext(playwright); + const site = await playwright.request.newContext(); + try { + const project = await setUp(ctx, { + ".deco/schema.gen.json": JSON.stringify(schema), + ".deco/blocks/hero-home.json": '{"__resolveType":"hero"}\n', + }); + await enableContentProtocol(ctx, project.org); + + // Nothing saved yet: no draft, so no pointer. + expect(await draftPointer(ctx, project, "draft-1")).toEqual({ + draft: null, + version: null, + }); + + const hero = { __resolveType: "hero", title: "Summer" }; + await rpc( + ctx, + rpcPath(project, "draft-1"), + "blocks.apply", + { + set: { "hero-home": hero }, + }, + ); + const pointer = await draftPointer(ctx, project, "draft-1"); + const host = new URL(DELIVERY_STUB_ORIGIN).host; + expect(pointer.draft).toMatch( + new RegExp( + `^${host}/sites/${project.owner}/drafts/[A-Za-z0-9_-]{22}\\.json$`, + ), + ); + expect(pointer.version).toEqual(expect.any(String)); + + // What the site's SDK fetches: the draft, then 304 while it is unchanged. + const url = `http://${pointer.draft}?v=${pointer.version}`; + const first = await site.get(url); + expect(first.status()).toBe(200); + expect(await first.json()).toEqual({ + set: { "hero-home": hero }, + delete: [], + }); + const etag = first.headers()["etag"]!; + const again = await site.get(url, { headers: { "If-None-Match": etag } }); + expect(again.status()).toBe(304); + + // Another save is a new ETag, so a new pointer version. + await rpc(ctx, rpcPath(project, "draft-1"), "blocks.apply", { + set: { "hero-home": { ...hero, title: "Winter" } }, + }); + const next = await draftPointer(ctx, project, "draft-1"); + expect(next.draft).toBe(pointer.draft); + expect(next.version).not.toBe(pointer.version); + expect( + (await site.get(url, { headers: { "If-None-Match": etag } })).status(), + ).toBe(200); + } finally { + await ctx.dispose(); + await site.dispose(); + } + }); + + test("publish commits the draft to main and makes its release current; Make current switches the CDN", async ({ + playwright, + }) => { + const ctx = await newApiContext(playwright); + try { + const project = await setUp(ctx, { + ".deco/schema.gen.json": JSON.stringify(schema), + ".deco/blocks/hero-home.json": '{"__resolveType":"hero"}\n', + }); + await enableContentProtocol(ctx, project.org); + const decofile = `/api/${project.org}/decofile/${project.vmcpId}`; + const hosted = `/api/${project.org}/hosted/${project.vmcpId}`; + const site = project.owner; + + const publish = async (title: string) => { + await rpc(ctx, rpcPath(project, "draft-1"), "blocks.apply", { + set: { "hero-home": { __resolveType: "hero", title } }, + }); + const res = await ctx.post(`${decofile}/draft-1/publish`, { + data: { note: `Publish ${title}` }, + }); + expect(res.status()).toBe(200); + return (await res.json()) as { + result: string; + sha: string; + release: string; + }; + }; + + const first = await publish("One"); + expect(first).toMatchObject({ result: "merged", release: "current" }); + const repo = await inspectStubRepo(ctx, project.owner, "site"); + expect(repo.refs.main).toBe(first.sha); + expect(repo.branches.main!.files[".deco/blocks/hero-home.json"]).toBe( + `${JSON.stringify({ __resolveType: "hero", title: "One" }, null, 2)}\n`, + ); + const objects = await deliveryObjects(ctx, `sites/${site}/`); + const revision = objects[`sites/${site}/revisions/${first.sha}.json`]!; + expect(JSON.parse(revision.text)).toMatchObject({ + revision: first.sha, + schemaHash: expect.stringMatching(/^[0-9a-f]{64}$/), + blocks: { "hero-home": { __resolveType: "hero", title: "One" } }, + }); + expect(revision.cacheControl).toBe("public, max-age=31536000, immutable"); + const latest = JSON.parse(objects[`sites/${site}/latest.json`]!.text); + expect(Object.keys(latest).sort()).toEqual([ + "publishedAt", + "revision", + "schemaHash", + ]); + expect(latest.revision).toBe(first.sha); + // The draft is gone; the next edit starts a new one. + expect(Object.keys(objects).some((k) => k.includes("/drafts/"))).toBe( + false, + ); + + const second = await publish("Two"); + let releases = await (await ctx.get(`${hosted}/releases`)).json(); + expect(releases).toMatchObject({ current: { revision: second.sha } }); + const publishedAt = (): Promise => + deliveryObjects(ctx, `sites/${site}/latest.json`).then( + (o) => JSON.parse(o[`sites/${site}/latest.json`]!.text).publishedAt, + ); + const secondAt = await publishedAt(); + expect( + releases.commits.map((c: { sha: string; hasRelease: boolean }) => [ + c.sha, + c.hasRelease, + ]), + ).toEqual([ + [second.sha, true], + [first.sha, true], + [expect.any(String), false], + ]); + + // Make an older release current: latest.json only, git stays. + const made = await ctx.post(`${hosted}/releases/current`, { + data: { sha: first.sha }, + }); + expect(made.status()).toBe(200); + // Every latest.json write is stamped now (the SDK's timeline rule). + const rolledBackAt = await publishedAt(); + expect(Date.parse(rolledBackAt)).toBeGreaterThan(Date.parse(secondAt)); + releases = await (await ctx.get(`${hosted}/releases`)).json(); + expect(releases).toMatchObject({ current: { revision: first.sha } }); + // A commit without a companion release can't be made current. + const initial = releases.commits.at(-1).sha as string; + expect( + ( + await ctx.post(`${hosted}/releases/current`, { + data: { sha: initial }, + }) + ).status(), + ).toBe(404); + + // A Publish that commits nothing leaves latest.json alone. + await rpc(ctx, rpcPath(project, "draft-1"), "blocks.apply", { + set: { "hero-home": { __resolveType: "hero", title: "Two" } }, + }); + const noop = await ctx.post(`${decofile}/draft-1/publish`, { + data: { note: "" }, + }); + expect(await noop.json()).toEqual({ result: "up-to-date" }); + expect(await publishedAt()).toBe(rolledBackAt); + } finally { + await ctx.dispose(); + } + }); + + test("issues site tokens, with no limit", async ({ playwright }) => { + const ctx = await newApiContext(playwright); + try { + const project = await setUp(ctx, { + ".deco/schema.gen.json": JSON.stringify(schema), + }); + await enableContentProtocol(ctx, project.org); + const tokens = `/api/${project.org}/hosted/${project.vmcpId}/site-tokens`; + + const issued = await ctx.post(tokens); + expect(issued.status()).toBe(200); + const { token, record } = (await issued.json()) as { + token: string; + record: { kid: string }; + }; + const payload = JSON.parse( + Buffer.from(token.split(".")[1]!, "base64url").toString(), + ); + expect(payload).toEqual({ + site: project.owner, + kid: record.kid, + iat: expect.any(Number), + }); + expect((await ctx.post(tokens)).status()).toBe(200); + expect((await ctx.post(tokens)).status()).toBe(200); + const listed = await (await ctx.get(tokens)).json(); + expect(listed.site).toBe(project.owner); + expect(listed.tokens).toHaveLength(3); + expect(JSON.stringify(listed)).not.toContain(token); + } finally { + await ctx.dispose(); + } + }); + + test("a project's site can't be changed, so hosted keys stay on it", async ({ + playwright, + }) => { + const ctx = await newApiContext(playwright); + try { + const project = await setUp(ctx, { + ".deco/schema.gen.json": JSON.stringify(schema), + }); + await enableContentProtocol(ctx, project.org); + // Hosted ownership is the `org_sites` link made at creation, never + // `metadata.siteSlug`, and the API refuses to rewrite the slug. + await expect( + callSelfMcpTool(ctx, project.org, "COLLECTION_VIRTUAL_MCP_UPDATE", { + id: project.vmcpId, + data: { metadata: { siteSlug: uniqueOwner() } }, + }), + ).rejects.toThrow(/site id can't change/); + const hosted = `/api/${project.org}/hosted/${project.vmcpId}`; + const listed = await (await ctx.get(`${hosted}/site-tokens`)).json(); + expect((listed as { site: string }).site).toBe(project.owner); + } finally { + await ctx.dispose(); + } + }); + + test("creating a project links its siteSlug, never another org's", async ({ + playwright, + }) => { + const ctx = await newApiContext(playwright); + const other = await newApiContext(playwright); + try { + // `setUp` creates the project with `siteSlug: owner`; nothing else + // links it, so site tokens working proves the link. + const project = await setUp(ctx, { + ".deco/schema.gen.json": JSON.stringify(schema), + }); + await enableContentProtocol(ctx, project.org); + const tokens = `/api/${project.org}/hosted/${project.vmcpId}/site-tokens`; + expect((await ctx.post(tokens)).status()).toBe(200); + + // Another org naming the same site gets no link and no hosted features. + const user = await signUpViaApi(other); + await enableContentProtocol(other, user.orgSlug); + const copy = await createFastPreviewProject(other, user.orgSlug, { + owner: project.owner, + repo: "site", + siteSlug: project.owner, + }); + const hosted = `/api/${copy.org}/hosted/${copy.vmcpId}`; + expect((await other.post(`${hosted}/site-tokens`)).status()).toBe(404); + // And the first org keeps it. + expect( + ((await (await ctx.get(tokens)).json()) as { site: string }).site, + ).toBe(project.owner); + } finally { + await ctx.dispose(); + await other.dispose(); + } + }); +}); + +/** A v7 site's `meta.gen.json`, as the classic editor reads it. */ +const v7Meta = { + manifest: { + blocks: { + pages: { "website/pages/Page.tsx": { $ref: "#/definitions/Page" } }, + sections: { + "site/sections/Hero.tsx": { $ref: "#/definitions/Hero" }, + }, + }, + }, + schema: { + definitions: { + Page: { type: "object", properties: {} }, + Hero: { + type: "object", + title: "Hero", + properties: { title: { type: "string", title: "Title" } }, + }, + }, + }, +}; + +const homePage = JSON.stringify({ + __resolveType: "website/pages/Page.tsx", + name: "Home", + path: "/", + sections: [{ __resolveType: "site/sections/Hero.tsx", title: "Hello" }], +}); + +/** A Fast Preview project in the signed-in user's org, with the flag on. */ +async function openEditor( + api: APIRequestContext, + orgSlug: string, + previewServerUrl: string, + files: Record, +): Promise<{ url: string; project: FastPreviewProject }> { + const owner = uniqueOwner(); + const project = await createFastPreviewProject(api, orgSlug, { + owner, + repo: "site", + // The header's PR lookup dials this; a closed port fails fast. + connectionUrl: "http://127.0.0.1:1/unused", + previewServerUrl, + siteSlug: owner, + }); + await seedStubRepo(api, { + owner, + repo: "site", + defaultBranch: "main", + branches: { main: { files } }, + }); + await enableContentProtocol(api, orgSlug); + const { item: thread } = await callSelfMcpTool<{ item: { id: string } }>( + api, + orgSlug, + "COLLECTION_THREADS_CREATE", + { data: { virtual_mcp_id: project.vmcpId, branch: "main" } }, + ); + return { + project, + url: `/${orgSlug}/projects/${project.vmcpId}/site-editor?thread=${thread.id}&sidepanel=false`, + }; +} + +test.describe("v7/v8 detection with the org flag on", () => { + test.setTimeout(120_000); + + for (const [name, file, meta] of [ + ["a v7 meta.gen.json", ".deco/meta.gen.json", v7Meta], + [ + "a meta.gen.json that names another major", + ".deco/meta.gen.json", + { ...v7Meta, blocksMajor: 7 }, + ], + ] as const) { + test(`${name} without "blocksMajor": 8 stays on the v7 editor`, async ({ + authedPage: { page, orgSlug }, + }) => { + const preview = await startPreviewSite(); + try { + const { url } = await openEditor(page.request, orgSlug, preview.url, { + [file]: JSON.stringify(meta), + ".deco/blocks/home.json": homePage, + }); + await page.goto(url); + // The classic editor (the org's `new_blocks_editor` is off): v8 + // sites always get the new one, so this is the v7 path. + const blocks = page.getByTestId("blocks-panel"); + await expect( + blocks.getByPlaceholder("Page name", { exact: true }), + ).toHaveValue("Home", { timeout: 60_000 }); + await expect(page.getByTestId("content-version-badge")).toHaveCount(0); + } finally { + await preview.close(); + } + }); + } + + test('a schema with "blocksMajor": 8 is a v8 site', async ({ + authedPage: { page, orgSlug }, + }) => { + const preview = await startPreviewSite(); + try { + const { url } = await openEditor(page.request, orgSlug, preview.url, { + ".deco/schema.gen.json": JSON.stringify({ ...v7Meta, blocksMajor: 8 }), + ".deco/blocks/home.json": homePage, + }); + await page.goto(url); + await expect(page.getByTestId("content-version-badge")).toHaveText("v8", { + timeout: 60_000, + }); + } finally { + await preview.close(); + } + }); + + test("a v7 site's decofile read and save still mint draft tokens", async ({ + playwright, + }) => { + const ctx = await newApiContext(playwright); + const anon = await newApiContext(playwright); + try { + const project = await setUp(ctx, { + ".deco/meta.gen.json": JSON.stringify(v7Meta), + ".deco/blocks/home.json": homePage, + }); + await enableContentProtocol(ctx, project.org); + const url = `/api/${project.org}/decofile/${project.vmcpId}/draft-1`; + + const saved = await ctx.patch(url, { + data: { set: { promo: { __resolveType: "site/sections/Hero.tsx" } } }, + }); + expect(saved.status()).toBe(200); + const { token } = (await saved.json()) as { token: string }; + expect(token).toEqual(expect.any(String)); + + const read = await ctx.get(url); + expect(read.status()).toBe(200); + expect(((await read.json()) as { token?: string }).token).toEqual( + expect.any(String), + ); + + // The draft link the site pulls still answers the whole decofile. + const pulled = await anon.get( + `${url}?token=${encodeURIComponent(token)}`, + ); + expect(pulled.status()).toBe(200); + const decofile = (await pulled.json()) as Record; + expect(decofile.promo).toEqual({ + __resolveType: "site/sections/Hero.tsx", + }); + } finally { + await ctx.dispose(); + await anon.dispose(); + } + }); +}); From acf03516651f992e6ac437c88ab8d89d8a4612d4 Mon Sep 17 00:00:00 2001 From: gimenes Date: Fri, 9 Oct 2026 12:06:50 -0300 Subject: [PATCH 2/2] test(e2e): Publish's Try again puts main's head live POST releases/current { head: true } after a rollback brings back the newest Publish. The test title drops CDN/Make current wording. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WNwbSEePYNcY5YCgqZURig --- packages/e2e/tests/content-protocol-github.spec.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/e2e/tests/content-protocol-github.spec.ts b/packages/e2e/tests/content-protocol-github.spec.ts index f695e05644..41d28e8a48 100644 --- a/packages/e2e/tests/content-protocol-github.spec.ts +++ b/packages/e2e/tests/content-protocol-github.spec.ts @@ -467,7 +467,7 @@ test.describe("content protocol on GitHub", () => { } }); - test("publish commits the draft to main and makes its release current; Make current switches the CDN", async ({ + test("Publish commits the draft to main and makes it live; publishing a version or Try again switches what the site serves", async ({ playwright, }) => { const ctx = await newApiContext(playwright); @@ -571,6 +571,18 @@ test.describe("content protocol on GitHub", () => { }); expect(await noop.json()).toEqual({ result: "up-to-date" }); expect(await publishedAt()).toBe(rolledBackAt); + + // Publish's "Try again" ({ head: true }) puts main's head live: the + // newest Publish, never an older one. + const retried = await ctx.post(`${hosted}/releases/current`, { + data: { head: true }, + }); + expect(retried.status()).toBe(200); + expect(await retried.json()).toMatchObject({ + current: { revision: second.sha }, + }); + releases = await (await ctx.get(`${hosted}/releases`)).json(); + expect(releases).toMatchObject({ current: { revision: second.sha } }); } finally { await ctx.dispose(); }