mirror of
https://github.com/webstudio-is/webstudio.git
synced 2026-11-02 10:08:45 +09:00
- **Restore points for agent work**: Create named restore points before major edits, review changes with a dry run, and safely revert project content if needed. - **Shared Slot authoring**: Agents can turn existing sections into shared Slots and insert linked copies across pages, including valid nested Slots. - **DTCG and Figma token imports**: Import design tokens and CSS variables through CLI, MCP, or direct paste, with aliases, modes, composite values, and mappings supported. - **Consistent token conflict handling**: Every paste workflow now offers the same preserve, rename, or merge choices instead of silently overwriting existing design tokens. - **Draft-page previews**: Agents can generate and inspect previews of draft pages without publishing them or exposing drafts in production builds. - **Safer MCP mutations**: Destructive operations require explicit confirmation, invalid raw patches and invented record IDs are rejected, and restore operations commit atomically. - **More dependable agent contracts**: New tools have complete runtime schemas, clearer validation errors, and consistent behavior across CLI, MCP, Builder, and server APIs.
494 lines
13 KiB
TypeScript
494 lines
13 KiB
TypeScript
import { afterEach, describe, expect, test, vi } from "vitest";
|
|
import { createDefaultPages } from "@webstudio-is/project-build";
|
|
import type { CompactBuild } from "@webstudio-is/project-build";
|
|
import {
|
|
createTestServer,
|
|
db,
|
|
json,
|
|
testContext,
|
|
} from "@webstudio-is/postgrest/testing";
|
|
import { buildPatchTransaction } from "@webstudio-is/protocol";
|
|
import type { AppContext } from "@webstudio-is/trpc-interface/index.server";
|
|
import { authorizeProject } from "@webstudio-is/trpc-interface/index.server";
|
|
import { db as authDb } from "@webstudio-is/authorization-token/index.server";
|
|
import * as projectApi from "@webstudio-is/project/index.server";
|
|
import * as projectBuild from "@webstudio-is/project-build/server";
|
|
import type { z } from "zod";
|
|
import {
|
|
buildPatchInput,
|
|
buildRestorePointInput,
|
|
commitBuildPatch,
|
|
commitBuildTransactions,
|
|
createBuildSnapshot,
|
|
loadBuildByProjectVersion,
|
|
loadReadableDevBuild,
|
|
serializeProjectSummary,
|
|
} from "./api-build.server";
|
|
|
|
test("separates validated restore points from raw build patches", () => {
|
|
const input = {
|
|
projectId: "project-1",
|
|
baseVersion: 1,
|
|
transactions: [
|
|
{
|
|
id: "restore-1",
|
|
payload: [
|
|
{
|
|
namespace: "instances" as const,
|
|
patches: [{ op: "replace" as const, path: [], value: [] }],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
};
|
|
|
|
expect(buildPatchInput.safeParse(input).success).toBe(false);
|
|
expect(buildRestorePointInput.safeParse(input).success).toBe(true);
|
|
expect(
|
|
buildRestorePointInput.safeParse({
|
|
...input,
|
|
transactions: [...input.transactions, ...input.transactions],
|
|
}).success
|
|
).toBe(false);
|
|
expect(
|
|
buildRestorePointInput.safeParse({
|
|
...input,
|
|
transactions: [
|
|
{
|
|
id: "restore-1",
|
|
payload: [
|
|
{
|
|
namespace: "instances",
|
|
patches: [{ op: "add", path: [], value: [] }],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
}).success
|
|
).toBe(false);
|
|
expect(
|
|
buildRestorePointInput.safeParse({
|
|
...input,
|
|
transactions: [
|
|
{
|
|
id: "restore-assets",
|
|
payload: [
|
|
{
|
|
namespace: "assets",
|
|
patches: [{ op: "replace", path: [], value: [] }],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
}).success
|
|
).toBe(false);
|
|
});
|
|
|
|
const server = createTestServer();
|
|
|
|
type ProjectSummaryInput = Parameters<typeof serializeProjectSummary>[0];
|
|
type SnapshotBuildInput = Parameters<typeof createBuildSnapshot>[0]["build"];
|
|
|
|
const createContext = (
|
|
allowAdditionalPermissions: boolean,
|
|
authorization: AppContext["authorization"] = {
|
|
type: "token",
|
|
authToken: "secret-token",
|
|
ownerId: "user-1",
|
|
}
|
|
) =>
|
|
({
|
|
authorization,
|
|
planFeatures: {
|
|
allowAdditionalPermissions,
|
|
},
|
|
}) as AppContext;
|
|
|
|
const createToken = (
|
|
overrides: Partial<Awaited<ReturnType<typeof authDb.getTokenInfo>>> = {}
|
|
) =>
|
|
({
|
|
token: "token-1",
|
|
projectId: "project-1",
|
|
name: "Token",
|
|
relation: "builders",
|
|
createdAt: "2024-01-01T00:00:00.000Z",
|
|
canClone: true,
|
|
canCopy: true,
|
|
canPublish: false,
|
|
canUseApi: true,
|
|
...overrides,
|
|
}) as Awaited<ReturnType<typeof authDb.getTokenInfo>>;
|
|
|
|
const createBuildRow = (overrides: Record<string, unknown> = {}) => ({
|
|
id: "build-1",
|
|
projectId: "project-1",
|
|
version: 3,
|
|
createdAt: "2024-01-01T00:00:00.000Z",
|
|
updatedAt: "2024-01-01T00:00:00.000Z",
|
|
pages: JSON.stringify({
|
|
meta: {},
|
|
homePage: {
|
|
id: "page-1",
|
|
name: "Home",
|
|
path: "",
|
|
title: "Home",
|
|
meta: {},
|
|
rootInstanceId: "root-1",
|
|
},
|
|
pages: [],
|
|
}),
|
|
breakpoints: JSON.stringify([]),
|
|
styles: JSON.stringify([]),
|
|
styleSources: JSON.stringify([]),
|
|
styleSourceSelections: JSON.stringify([]),
|
|
props: JSON.stringify([]),
|
|
dataSources: JSON.stringify([]),
|
|
resources: JSON.stringify([]),
|
|
instances: JSON.stringify([
|
|
{ id: "root-1", type: "instance", component: "Body", children: [] },
|
|
]),
|
|
deployment: null,
|
|
marketplaceProduct: JSON.stringify({}),
|
|
projectSettings: JSON.stringify({ meta: {}, compiler: {} }),
|
|
...overrides,
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
describe("api build loading", () => {
|
|
test("loads a specific project build version", async () => {
|
|
server.use(db.get("Build", () => json([createBuildRow()])));
|
|
|
|
await expect(
|
|
loadBuildByProjectVersion(testContext as AppContext, "project-1", 3)
|
|
).resolves.toMatchObject({
|
|
id: "build-1",
|
|
projectId: "project-1",
|
|
version: 3,
|
|
});
|
|
});
|
|
|
|
test("throws not found when project build version is missing", async () => {
|
|
server.use(db.get("Build", () => json([])));
|
|
|
|
await expect(
|
|
loadBuildByProjectVersion(testContext as AppContext, "project-1", 99)
|
|
).rejects.toMatchObject({
|
|
code: "NOT_FOUND",
|
|
message: "Build version not found for project",
|
|
});
|
|
});
|
|
|
|
test("checks view permission before loading readable dev build", async () => {
|
|
const ctx = createContext(true);
|
|
const build = { id: "build-1" } as Awaited<
|
|
ReturnType<typeof projectBuild.loadDevBuildByProjectId>
|
|
>;
|
|
vi.spyOn(authDb, "getTokenInfo").mockResolvedValue(createToken());
|
|
vi.spyOn(authorizeProject, "hasProjectPermit").mockResolvedValue(true);
|
|
const loadDevBuild = vi
|
|
.spyOn(projectBuild, "loadDevBuildByProjectId")
|
|
.mockResolvedValue(build);
|
|
|
|
await expect(loadReadableDevBuild(ctx, "project-1")).resolves.toBe(build);
|
|
|
|
expect(loadDevBuild).toHaveBeenCalledWith(ctx, "project-1");
|
|
});
|
|
});
|
|
|
|
describe("api build patch commits", () => {
|
|
const build = {
|
|
id: "build",
|
|
projectId: "project",
|
|
version: 1,
|
|
} as CompactBuild;
|
|
const transaction: z.infer<typeof buildPatchTransaction> = {
|
|
id: "transaction",
|
|
payload: [
|
|
{
|
|
namespace: "pages",
|
|
patches: [{ op: "replace", path: ["meta", "siteName"], value: "Site" }],
|
|
},
|
|
],
|
|
};
|
|
|
|
test("commits transactions and semantic payloads", async () => {
|
|
const ctx = createContext(true);
|
|
const patchBuild = vi
|
|
.spyOn(projectApi, "patchBuild")
|
|
.mockResolvedValue({ status: "ok", version: 2 });
|
|
|
|
await expect(
|
|
commitBuildTransactions({
|
|
ctx,
|
|
projectId: "project",
|
|
buildId: "build",
|
|
clientVersion: 1,
|
|
transactions: [transaction],
|
|
})
|
|
).resolves.toEqual({ version: 2 });
|
|
await expect(
|
|
commitBuildPatch({
|
|
build,
|
|
ctx,
|
|
projectId: "project",
|
|
payload: transaction.payload,
|
|
})
|
|
).resolves.toEqual({ version: 2 });
|
|
|
|
expect(patchBuild).toHaveBeenNthCalledWith(
|
|
1,
|
|
{
|
|
buildId: "build",
|
|
projectId: "project",
|
|
clientVersion: 1,
|
|
transactions: [transaction],
|
|
},
|
|
ctx
|
|
);
|
|
expect(patchBuild).toHaveBeenNthCalledWith(
|
|
2,
|
|
{
|
|
buildId: "build",
|
|
projectId: "project",
|
|
clientVersion: 1,
|
|
transactions: [
|
|
{
|
|
id: expect.any(String),
|
|
payload: transaction.payload,
|
|
},
|
|
],
|
|
},
|
|
ctx
|
|
);
|
|
});
|
|
|
|
test("maps patch conflicts and errors to TRPC errors", async () => {
|
|
const ctx = createContext(true);
|
|
const patchBuild = vi.spyOn(projectApi, "patchBuild");
|
|
|
|
patchBuild.mockResolvedValueOnce({
|
|
status: "version_mismatched",
|
|
errors: "Version changed",
|
|
});
|
|
await expect(
|
|
commitBuildTransactions({
|
|
ctx,
|
|
projectId: "project",
|
|
buildId: "build",
|
|
clientVersion: 1,
|
|
transactions: [transaction],
|
|
})
|
|
).rejects.toMatchObject({
|
|
code: "CONFLICT",
|
|
message: "Version changed",
|
|
});
|
|
|
|
patchBuild.mockResolvedValueOnce({
|
|
status: "error",
|
|
errors: "Invalid patch",
|
|
});
|
|
await expect(
|
|
commitBuildTransactions({
|
|
ctx,
|
|
projectId: "project",
|
|
buildId: "build",
|
|
clientVersion: 1,
|
|
transactions: [transaction],
|
|
})
|
|
).rejects.toMatchObject({
|
|
code: "BAD_REQUEST",
|
|
message: "Invalid patch",
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("api build snapshot", () => {
|
|
test("returns only requested build namespaces", () => {
|
|
const tokenStyleSource = {
|
|
type: "token" as const,
|
|
id: "token-1",
|
|
name: "Primary",
|
|
};
|
|
const localStyleSource = { type: "local" as const, id: "local-1" };
|
|
const build = {
|
|
id: "build-1",
|
|
projectId: "project-1",
|
|
version: 7,
|
|
pages: createDefaultPages({ rootInstanceId: "root-1" }),
|
|
instances: [{ type: "instance", id: "root-1", component: "Body" }],
|
|
props: [],
|
|
styles: [],
|
|
styleSources: [tokenStyleSource, localStyleSource],
|
|
styleSourceSelections: [],
|
|
resources: [],
|
|
dataSources: [],
|
|
breakpoints: [],
|
|
} as unknown as SnapshotBuildInput;
|
|
|
|
expect(
|
|
createBuildSnapshot({
|
|
build,
|
|
include: new Set(["designTokens", "instances"]),
|
|
projectId: "project-1",
|
|
})
|
|
).toEqual({
|
|
projectId: "project-1",
|
|
buildId: "build-1",
|
|
version: 7,
|
|
designTokens: [tokenStyleSource],
|
|
instances: [{ type: "instance", id: "root-1", component: "Body" }],
|
|
});
|
|
});
|
|
|
|
test("returns page folders and data namespaces when requested", () => {
|
|
const pages = createDefaultPages({ rootInstanceId: "root-1" });
|
|
pages.redirects = [{ old: "/old", new: "/new", status: "301" }];
|
|
pages.pageTemplates = new Map([
|
|
[
|
|
"template-1",
|
|
{
|
|
id: "template-1",
|
|
name: "Landing",
|
|
title: "Landing",
|
|
rootInstanceId: "template-root-1",
|
|
meta: {},
|
|
},
|
|
],
|
|
]);
|
|
const build = {
|
|
id: "build-1",
|
|
projectId: "project-1",
|
|
version: 7,
|
|
pages,
|
|
instances: [],
|
|
props: [],
|
|
styles: [],
|
|
styleSources: [],
|
|
styleSourceSelections: [],
|
|
resources: [{ id: "resource-1" }],
|
|
dataSources: [{ id: "variable-1" }],
|
|
breakpoints: [{ id: "breakpoint-1" }],
|
|
marketplaceProduct: { categories: ["template"] },
|
|
projectSettings: {
|
|
meta: { siteName: "Acme" },
|
|
compiler: { atomicStyles: true },
|
|
},
|
|
} as unknown as SnapshotBuildInput;
|
|
|
|
expect(
|
|
createBuildSnapshot({
|
|
build,
|
|
include: new Set([
|
|
"pages",
|
|
"folders",
|
|
"resources",
|
|
"variables",
|
|
"breakpoints",
|
|
"projectSettings",
|
|
"marketplaceProduct",
|
|
]),
|
|
projectId: "project-1",
|
|
})
|
|
).toEqual(
|
|
expect.objectContaining({
|
|
projectId: "project-1",
|
|
buildId: "build-1",
|
|
version: 7,
|
|
homePageId: build.pages.homePageId,
|
|
rootFolderId: build.pages.rootFolderId,
|
|
projectSettings: {
|
|
meta: { siteName: "Acme" },
|
|
compiler: { atomicStyles: true },
|
|
},
|
|
redirects: [{ old: "/old", new: "/new", status: "301" }],
|
|
pageTemplates: [
|
|
{
|
|
id: "template-1",
|
|
name: "Landing",
|
|
title: "Landing",
|
|
rootInstanceId: "template-root-1",
|
|
meta: {},
|
|
},
|
|
],
|
|
resources: [{ id: "resource-1" }],
|
|
variables: [{ id: "variable-1" }],
|
|
breakpoints: [{ id: "breakpoint-1" }],
|
|
marketplaceProduct: { categories: ["template"] },
|
|
})
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("api project serialization", () => {
|
|
test("uses latest virtual build time as project updated time", () => {
|
|
const project = {
|
|
id: "project-1",
|
|
title: "Project",
|
|
domain: "example.com",
|
|
createdAt: "2024-01-01T00:00:00.000Z",
|
|
latestBuildVirtual: {
|
|
createdAt: "2024-01-03T00:00:00.000Z",
|
|
},
|
|
latestStaticBuild: {
|
|
updatedAt: "2024-01-02T00:00:00.000Z",
|
|
},
|
|
} as unknown as ProjectSummaryInput;
|
|
|
|
expect(serializeProjectSummary(project)).toEqual({
|
|
id: "project-1",
|
|
name: "Project",
|
|
domain: "example.com",
|
|
createdAt: "2024-01-01T00:00:00.000Z",
|
|
updatedAt: "2024-01-03T00:00:00.000Z",
|
|
});
|
|
});
|
|
|
|
test("falls back to latest static build and then project creation time", () => {
|
|
expect(
|
|
serializeProjectSummary({
|
|
id: "project-1",
|
|
title: "Project",
|
|
domain: null,
|
|
createdAt: "2024-01-01T00:00:00.000Z",
|
|
latestBuildVirtual: null,
|
|
latestStaticBuild: {
|
|
updatedAt: "2024-01-02T00:00:00.000Z",
|
|
},
|
|
} as unknown as ProjectSummaryInput)
|
|
).toEqual({
|
|
id: "project-1",
|
|
name: "Project",
|
|
domain: undefined,
|
|
createdAt: "2024-01-01T00:00:00.000Z",
|
|
updatedAt: "2024-01-02T00:00:00.000Z",
|
|
});
|
|
|
|
expect(
|
|
serializeProjectSummary({
|
|
id: "project-1",
|
|
title: "Project",
|
|
domain: null,
|
|
createdAt: "2024-01-01T00:00:00.000Z",
|
|
latestBuildVirtual: null,
|
|
latestStaticBuild: null,
|
|
} as unknown as ProjectSummaryInput)
|
|
).toEqual({
|
|
id: "project-1",
|
|
name: "Project",
|
|
domain: undefined,
|
|
createdAt: "2024-01-01T00:00:00.000Z",
|
|
updatedAt: "2024-01-01T00:00:00.000Z",
|
|
});
|
|
});
|
|
|
|
test("throws not found when project is missing", () => {
|
|
expect(() =>
|
|
serializeProjectSummary(null as unknown as ProjectSummaryInput)
|
|
).toThrow("Project not found");
|
|
});
|
|
});
|