mirror of
https://github.com/webstudio-is/webstudio.git
synced 2026-10-28 07:37:36 +09:00
## Outcome Restore the Marketplace section for projects without existing marketplace metadata, make the complete review-submission workflow available over MCP, clarify shared Assets database diagnostics, and reduce published database size by materializing fully static Assets queries. ## Implementation ### Marketplace and MCP - Initialize the Marketplace form from a safe partial default instead of rendering nothing when metadata is unconfigured. - Keep review submission disabled until the complete metadata schema is valid. - Allow `get-marketplace-product` to return `null` and `update-marketplace-product` to create the first marketplace product through ProjectSession/MCP. - Add a confirmed `submit-marketplace-product` server operation that validates configured metadata before setting review status to `PENDING`. - Document the MCP workflow: upload the thumbnail, update metadata, publish production, then submit. - Correct the Marketplace thumbnail upload example to use the current MCP input field. ### Assets database - Show query size and merged published-database size separately in resource diagnostics. - Clarify that only the merged database counts toward the publish limit. - Allow the Assets query editor to scroll long query source. - Materialize fully static metadata-only Assets queries at publish time. - Store materialized results as compact field columns and rows keyed by a deterministic normalized-query hash. - Serve materialized rows through the existing Assets response contract without candidate documents or a runtime field catalog. - Keep route- or variable-dependent queries on the existing document-backed runtime path. - Exclude overview-only fields from dynamic detail documents so the merged database does not duplicate them. - Preserve byte-limit selection, truncation diagnostics, integrity verification, caching, and backward compatibility with existing artifacts. ### Release - Keep the release lockfile stable when preparing the CLI package. - Regenerate runtime contracts, server-only API metadata, and CLI documentation where required. ## Todo - [x] Restore the empty Marketplace settings state - [x] Prevent empty review submissions - [x] Support first-time Marketplace metadata creation over MCP - [x] Add MCP review submission - [x] Document the end-to-end MCP workflow - [x] Correct query and merged-database diagnostics - [x] Make long Assets query source scrollable - [x] Materialize static overview queries - [x] Preserve dynamic detail-query behavior - [x] Add regression coverage with fail/pass proof - [x] Run relevant package and integration checks ## Verification - [x] Content engine tests: 158 passed - [x] Asset uploader tests: 179 passed - [x] SDK tests: 505 passed - [x] CLI tests: 921 passed - [x] CLI prebuild integration tests: 46 passed - [x] Builder Assets resource and diagnostics tests: 14 passed - [x] Project runtime and ProjectSession tests: 133 passed - [x] Builder API router tests: 30 passed - [x] Protocol operation tests: 18 passed - [x] Content engine build and declaration generation - [x] Content engine, asset uploader, SDK, Builder, and CLI typechecks - [x] Repository lint and formatting checks - [x] Generated API and documentation checks - [x] New materialization tests demonstrated failing with the implementation disabled and passing after restoration ## Notes - The representative five-post overview fixture shrinks from 2,122 bytes to 1,197 bytes while returning the same sorted and paginated result. - Materialization is intentionally limited to static, metadata-only field queries without runtime asset fields. Unsupported or data-incompatible queries safely use the existing runtime database. - The complete `pnpm evaluations` agent suite was not run because repository policy requires separate explicit approval. - No database schema or migration changes are required.
372 lines
9.5 KiB
TypeScript
372 lines
9.5 KiB
TypeScript
import { describe, expect, test } from "vitest";
|
|
import type { Instance, WebstudioData } from "@webstudio-is/sdk";
|
|
import { createDefaultPages } from "@webstudio-is/project-build";
|
|
import type {
|
|
MarketplaceProduct,
|
|
ProjectSettings,
|
|
} from "@webstudio-is/project-build";
|
|
import type { BuilderPatchChange } from "@webstudio-is/project-build/contracts";
|
|
import {
|
|
createSyncChangesFromBuilderPatchPayload,
|
|
createTransactionFromBuilderPatchPayload,
|
|
} from "./builder-patch";
|
|
import { registerContainers, serverSyncStore } from "./sync-stores";
|
|
import {
|
|
$assets,
|
|
$breakpoints,
|
|
$dataSources,
|
|
$instances,
|
|
$marketplaceProduct,
|
|
$pages,
|
|
$projectSettings,
|
|
$props,
|
|
$resources,
|
|
$styleSourceSelections,
|
|
$styleSources,
|
|
$styles,
|
|
} from "./data-stores";
|
|
|
|
registerContainers();
|
|
|
|
const createInstance = (
|
|
id: Instance["id"],
|
|
children: Instance["children"] = []
|
|
): Instance => ({
|
|
type: "instance",
|
|
id,
|
|
component: "ws:element",
|
|
tag: "div",
|
|
children,
|
|
});
|
|
|
|
type TestData = WebstudioData & {
|
|
marketplaceProduct?: MarketplaceProduct;
|
|
projectSettings: ProjectSettings;
|
|
};
|
|
|
|
const createData = (): TestData => ({
|
|
pages: createDefaultPages({ rootInstanceId: "body" }),
|
|
instances: new Map([
|
|
["body", createInstance("body")],
|
|
["existing", createInstance("existing", [{ type: "id", value: "old" }])],
|
|
["removed", createInstance("removed")],
|
|
]),
|
|
props: new Map(),
|
|
dataSources: new Map(),
|
|
resources: new Map(),
|
|
breakpoints: new Map(),
|
|
styleSourceSelections: new Map(),
|
|
styleSources: new Map(),
|
|
styles: new Map(),
|
|
assets: new Map(),
|
|
projectSettings: { meta: {}, compiler: {} },
|
|
});
|
|
|
|
const setupStores = (data: TestData) => {
|
|
serverSyncStore.transactionManager.currentStack = [];
|
|
serverSyncStore.transactionManager.undoneStack = [];
|
|
serverSyncStore.popAll();
|
|
$pages.set(data.pages);
|
|
$instances.set(data.instances);
|
|
$props.set(data.props);
|
|
$dataSources.set(data.dataSources);
|
|
$resources.set(data.resources);
|
|
$breakpoints.set(data.breakpoints);
|
|
$styleSourceSelections.set(data.styleSourceSelections);
|
|
$styleSources.set(data.styleSources);
|
|
$styles.set(data.styles);
|
|
$assets.set(data.assets);
|
|
$marketplaceProduct.set(data.marketplaceProduct);
|
|
$projectSettings.set(data.projectSettings);
|
|
};
|
|
|
|
describe("builder patch sync adapter", () => {
|
|
test("converts builder patches into sync changes with reverse patches", () => {
|
|
const data = createData();
|
|
const added = createInstance("added");
|
|
const payload: BuilderPatchChange[] = [
|
|
{ namespace: "props", patches: [] },
|
|
{
|
|
namespace: "instances",
|
|
patches: [
|
|
{ op: "add", path: ["added"], value: added },
|
|
{
|
|
op: "add",
|
|
path: ["added", "children", 0],
|
|
value: { type: "id", value: "child" },
|
|
},
|
|
{
|
|
op: "replace",
|
|
path: ["existing", "children", 0, "value"],
|
|
value: "new",
|
|
},
|
|
{ op: "remove", path: ["removed"] },
|
|
],
|
|
},
|
|
];
|
|
|
|
const changes = createSyncChangesFromBuilderPatchPayload({
|
|
data,
|
|
payload,
|
|
});
|
|
|
|
expect(changes).toHaveLength(1);
|
|
const [change] = changes;
|
|
expect(change.namespace).toEqual("instances");
|
|
expect(change.patches).toEqual([
|
|
{
|
|
op: "add",
|
|
path: ["added"],
|
|
value: createInstance("added"),
|
|
},
|
|
{
|
|
op: "add",
|
|
path: ["added", "children", 0],
|
|
value: { type: "id", value: "child" },
|
|
},
|
|
{
|
|
op: "replace",
|
|
path: ["existing", "children", 0, "value"],
|
|
value: "new",
|
|
},
|
|
{ op: "remove", path: ["removed"] },
|
|
]);
|
|
expect(change.revisePatches).toEqual(
|
|
expect.arrayContaining([
|
|
{ op: "remove", path: ["added"] },
|
|
{
|
|
op: "replace",
|
|
path: ["existing", "children", 0, "value"],
|
|
value: "old",
|
|
},
|
|
{ op: "add", path: ["removed"], value: createInstance("removed") },
|
|
])
|
|
);
|
|
|
|
added.children.push({ type: "id", value: "mutated-after-conversion" });
|
|
expect(change.patches[0]).toEqual({
|
|
op: "add",
|
|
path: ["added"],
|
|
value: createInstance("added"),
|
|
});
|
|
});
|
|
|
|
test("compacts reverse patches for undo", () => {
|
|
const data = createData();
|
|
const added = createInstance("added");
|
|
const payload: BuilderPatchChange[] = [
|
|
{
|
|
namespace: "instances",
|
|
patches: [
|
|
{ op: "add", path: ["added"], value: added },
|
|
{
|
|
op: "add",
|
|
path: ["added", "children", 0],
|
|
value: { type: "id", value: "child" },
|
|
},
|
|
],
|
|
},
|
|
];
|
|
|
|
const changes = createSyncChangesFromBuilderPatchPayload({
|
|
data,
|
|
payload,
|
|
});
|
|
|
|
expect(changes).toHaveLength(1);
|
|
const [change] = changes;
|
|
expect(change.revisePatches).toEqual([
|
|
{
|
|
op: "remove",
|
|
path: ["added"],
|
|
},
|
|
]);
|
|
expect(change.patches).toEqual([
|
|
{
|
|
op: "add",
|
|
path: ["added"],
|
|
value: createInstance("added"),
|
|
},
|
|
{
|
|
op: "add",
|
|
path: ["added", "children", 0],
|
|
value: { type: "id", value: "child" },
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("preserves runtime add patches for undefined object fields", () => {
|
|
const data = createData();
|
|
const page = data.pages.pages.get(data.pages.homePageId);
|
|
if (page === undefined) {
|
|
throw Error("Expected fixture page");
|
|
}
|
|
page.marketplace = {
|
|
include: true,
|
|
category: undefined,
|
|
thumbnailAssetId: undefined,
|
|
};
|
|
|
|
const changes = createSyncChangesFromBuilderPatchPayload({
|
|
data,
|
|
payload: [
|
|
{
|
|
namespace: "pages",
|
|
patches: [
|
|
{
|
|
op: "add",
|
|
path: ["pages", data.pages.homePageId, "marketplace", "category"],
|
|
value: "Runtime Examples",
|
|
},
|
|
],
|
|
},
|
|
],
|
|
});
|
|
|
|
expect(changes).toEqual([
|
|
{
|
|
namespace: "pages",
|
|
patches: [
|
|
{
|
|
op: "add",
|
|
path: ["pages", data.pages.homePageId, "marketplace", "category"],
|
|
value: "Runtime Examples",
|
|
},
|
|
],
|
|
revisePatches: [
|
|
{
|
|
op: "replace",
|
|
path: ["pages", data.pages.homePageId, "marketplace", "category"],
|
|
value: undefined,
|
|
},
|
|
],
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("commits builder patch payloads through sync undo and redo", () => {
|
|
const data = createData();
|
|
setupStores(data);
|
|
|
|
createTransactionFromBuilderPatchPayload({
|
|
data,
|
|
payload: [
|
|
{
|
|
namespace: "instances",
|
|
patches: [
|
|
{
|
|
op: "replace",
|
|
path: ["existing", "children", 0, "value"],
|
|
value: "new",
|
|
},
|
|
],
|
|
},
|
|
],
|
|
});
|
|
|
|
expect($instances.get().get("existing")?.children).toEqual([
|
|
{ type: "id", value: "new" },
|
|
]);
|
|
|
|
serverSyncStore.undo();
|
|
expect($instances.get().get("existing")?.children).toEqual([
|
|
{ type: "id", value: "old" },
|
|
]);
|
|
|
|
serverSyncStore.redo();
|
|
expect($instances.get().get("existing")?.children).toEqual([
|
|
{ type: "id", value: "new" },
|
|
]);
|
|
});
|
|
|
|
test("commits project settings through sync undo and redo", () => {
|
|
const data = createData();
|
|
data.projectSettings.meta.siteName = "Before";
|
|
setupStores(data);
|
|
|
|
createTransactionFromBuilderPatchPayload({
|
|
data,
|
|
payload: [
|
|
{
|
|
namespace: "projectSettings",
|
|
patches: [
|
|
{
|
|
op: "replace",
|
|
path: ["meta", "siteName"],
|
|
value: "After",
|
|
},
|
|
],
|
|
},
|
|
],
|
|
});
|
|
|
|
expect($projectSettings.get()?.meta.siteName).toBe("After");
|
|
|
|
serverSyncStore.undo();
|
|
expect($projectSettings.get()?.meta.siteName).toBe("Before");
|
|
|
|
serverSyncStore.redo();
|
|
expect($projectSettings.get()?.meta.siteName).toBe("After");
|
|
});
|
|
|
|
test("creates marketplace metadata through sync undo and redo", () => {
|
|
const data = createData();
|
|
const marketplaceProduct = {
|
|
category: "pageTemplates" as const,
|
|
name: "Acme Template",
|
|
thumbnailAssetId: "asset-id",
|
|
author: "Acme Studio",
|
|
email: "hello@example.com",
|
|
website: "https://example.com",
|
|
issues: "",
|
|
description: "Reusable template project for Acme landing pages.",
|
|
};
|
|
setupStores(data);
|
|
|
|
createTransactionFromBuilderPatchPayload({
|
|
data,
|
|
payload: [
|
|
{
|
|
namespace: "marketplaceProduct",
|
|
patches: [{ op: "replace", path: [], value: marketplaceProduct }],
|
|
},
|
|
],
|
|
});
|
|
|
|
expect($marketplaceProduct.get()).toEqual(marketplaceProduct);
|
|
serverSyncStore.undo();
|
|
expect($marketplaceProduct.get()).toBeUndefined();
|
|
serverSyncStore.redo();
|
|
expect($marketplaceProduct.get()).toEqual(marketplaceProduct);
|
|
});
|
|
|
|
test("commits style source selection removals through sync undo and redo", () => {
|
|
const data = createData();
|
|
data.styleSourceSelections.set("card", {
|
|
instanceId: "card",
|
|
values: ["local"],
|
|
});
|
|
setupStores(data);
|
|
|
|
createTransactionFromBuilderPatchPayload({
|
|
data,
|
|
payload: [
|
|
{
|
|
namespace: "styleSourceSelections",
|
|
patches: [{ op: "remove", path: ["card"] }],
|
|
},
|
|
],
|
|
});
|
|
|
|
expect($styleSourceSelections.get()).toEqual(new Map());
|
|
|
|
serverSyncStore.undo();
|
|
expect($styleSourceSelections.get()).toEqual(
|
|
new Map([["card", { instanceId: "card", values: ["local"] }]])
|
|
);
|
|
|
|
serverSyncStore.redo();
|
|
expect($styleSourceSelections.get()).toEqual(new Map());
|
|
});
|
|
});
|