fix: Sync current projects and preserve imported asset folders

This commit is contained in:
Oleg Isonen committed 2026-09-09 01:57:12 +01:00
1 parent 21015fce29
commit a2a567551c
15 files changed
+489 -197

No files matched your search

+12 -2
View File
@@ -96,7 +96,7 @@ Or link a Project non-interactively:
npx webstudio link --link "<share-link-with-build-access>"
```
Publish the Project in Webstudio Cloud before syncing when you need recent Builder changes in the local export.
Sync exports the current saved Builder project and its asset files. Publishing is not required.
## Install Node.js only when needed
@@ -166,12 +166,22 @@ The share link should include Build access when you need to sync, build, import,
## Export and build a Project
After linking, publish the Project in Webstudio Cloud, then sync it locally:
After linking, sync the current saved Project locally:
```bash
npx webstudio sync
```
Sync refreshes the project from the server, downloads its asset files, and prepares the local content index. It does not combine an older published build with current assets.
To replace another Project with the synced Project:
```bash
npx webstudio import --to "<target-share-link-with-build-access>"
```
Import replaces the target's pages, assets, and asset folders. It preserves file formats and folder assignments; it does not convert Markdown files to MDX.
Build a dynamic app with a deployment template:
```bash
@@ -1,11 +1,11 @@
{
"bundleVersion": "bundle-1x6rafk",
"build": {
"id": "8f0fdff2-e76b-4f86-9203-ea7df5f1143c",
"id": "823cc69d-b2ed-418b-823c-2efc717319cd",
"projectId": "8a7358b1-7de3-459d-b7b1-56dddfb6ce1e",
"version": 7,
"createdAt": "2026-01-04T10:46:48.415+00:00",
"updatedAt": "2026-01-04T10:46:48.415+00:00",
"createdAt": "2024-09-03T04:31:42.776+00:00",
"updatedAt": "2026-01-04T10:46:33.246+00:00",
"pages": {
"homePageId": "Y60VJnVZMU_ifI0GnAR1N",
"rootFolderId": "root",
@@ -147,12 +147,6 @@
"projectSettings": {
"meta": {},
"compiler": {}
},
"deployment": {
"destination": "saas",
"domains": ["fixture-client-do-not-touch-wsmel"],
"assetsDomain": "fixture-client-do-not-touch-wsmel",
"excludeWstdDomainFromSearch": false
}
},
"page": {
@@ -202,9 +196,6 @@
],
"assets": [],
"assetFolders": [],
"user": {
"email": "hello@webstudio.is"
},
"projectDomain": "fixture-client-do-not-touch-wsmel",
"projectTitle": "FIXTURE-CLIENT-DO-NOT-TOUCH",
"origin": "https://main.development.webstudio.is"
@@ -1,2 +1,2 @@
export const assetQueryDeploymentId = "8f0fdff2-e76b-4f86-9203-ea7df5f1143c";
export const assetQueryDeploymentId = "823cc69d-b2ed-418b-823c-2efc717319cd";
export const assetQueryDatabase = undefined;
@@ -43,5 +43,5 @@ export const getRemixParams = ({ ...params }: Params): Params => {
}
export const contactEmail = "hello@webstudio.is";
export const contactEmail = undefined;
@@ -12,7 +12,7 @@
export const projectDomain = "fixture-client-do-not-touch-wsmel";
export const lastPublished = "2026-01-04T10:46:48.415Z";
export const lastPublished = "2024-09-03T04:31:42.776Z";
export const siteName = undefined;
@@ -43,5 +43,5 @@ export const getRemixParams = ({ ...params }: Params): Params => {
}
export const contactEmail = "hello@webstudio.is";
export const contactEmail = undefined;
@@ -13,7 +13,7 @@
export const projectDomain = "fixture-client-do-not-touch-wsmel";
export const lastPublished = "2026-01-04T10:46:48.415Z";
export const lastPublished = "2024-09-03T04:31:42.776Z";
export const siteName = undefined;
+2 -2
View File
@@ -113,9 +113,9 @@ Once the project is linked, use the **`sync`** command to sync it with the cloud
webstudio sync
```
Make sure to publish the project in Webstudio Cloud before running the **`sync`** command in your local Webstudio project.
Sync downloads the current saved Builder project and its asset files, then prepares the local content index. Publishing is not required.
`sync` is only for downloading a published project export. It is not part of the MCP editing workflow.
Use the export with `webstudio build` or `webstudio import --to "<target-share-link>"`. Sync is not required for MCP editing.
### build
@@ -26,7 +26,10 @@ import {
import { migratePages } from "@webstudio-is/project-migrations/pages";
import React from "react";
import type { BuilderState } from "@webstudio-is/project-build/state";
import { createBuilderStateFreshness } from "@webstudio-is/project-build/state";
import {
createBuilderStateFreshness,
createBuilderBuildDataSnapshotFromState,
} from "@webstudio-is/project-build/state";
import type { BuilderRuntimeMutation } from "@webstudio-is/project-build/runtime";
import type { BuilderPatchTransaction } from "@webstudio-is/project-build/contracts";
import type { HighImpactFixture, EvaluationProject } from "./fixtures";
@@ -350,8 +353,11 @@ export const startHighImpactFixtureApi = async (
);
return;
}
if (operationPath === "build.loadProjectBundleByProjectId") {
data = createLocalProjectBundleFromSessionSnapshot(
if (
operationPath === "build.loadProjectBundleByProjectId" ||
operationPath === "build.loadData"
) {
const bundle = createLocalProjectBundleFromSessionSnapshot(
{
projectId,
buildId,
@@ -367,6 +373,21 @@ export const startHighImpactFixtureApi = async (
},
{ origin }
);
data =
operationPath === "build.loadData"
? {
...bundle.build,
...createBuilderBuildDataSnapshotFromState(state),
pages: bundle.build.pages,
assets: bundle.assets,
assetFolders: bundle.assetFolders,
project: {
id: projectId,
title: "High-impact evaluation",
domain: "high-impact-evaluation",
},
}
: bundle;
} else if (operationPath === "projects.get") {
data = {
id: projectId,
+16 -1
View File
@@ -607,7 +607,22 @@ const startFixtureApi = async (
return undefined;
}
let data: unknown;
if (operationPath === "build.loadProjectBundleByProjectId") {
if (operationPath === "build.loadData") {
const snapshot = getCurrentBuildSnapshot();
data = {
...getCurrentSerializedBuild(),
...snapshot,
pages: getCurrentSerializedBuild().pages,
dataSources: snapshot.variables,
assets: Array.from(state.assets?.values() ?? []),
assetFolders: Array.from(state.assetFolders?.values() ?? []),
project: {
id: projectId,
title: "Release smoke project",
domain: "release-smoke",
},
};
} else if (operationPath === "build.loadProjectBundleByProjectId") {
data = { ...fixture.data, build: getCurrentSerializedBuild() };
} else if (operationPath === "projects.get") {
data = {
+2 -2
View File
@@ -234,7 +234,7 @@ test("prepares preview by syncing missing data and generating the app template",
downloadAssetFiles: vi.fn(async () => undefined),
isFileExists: vi.fn(async () => true),
loadProjectBundleByBuildId: vi.fn(),
loadProjectBundleByProjectId: vi.fn(async () =>
loadCurrentProjectBundle: vi.fn(async () =>
createPublishedProjectBundleFixture()
),
readFile: vi.fn(),
@@ -266,7 +266,7 @@ test("prepares preview by syncing missing data and generating the app template",
ensureDependencies: vi.fn(async () => undefined),
});
expect(syncDependencies.loadProjectBundleByProjectId).toHaveBeenCalled();
expect(syncDependencies.loadCurrentProjectBundle).toHaveBeenCalled();
expect(prebuildProject).toHaveBeenCalledWith({
assets: true,
template: ["react-router"],
+123 -19
View File
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, expect, test, vi } from "vitest";
import { access, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { createServer } from "node:http";
import { bundleVersion } from "@webstudio-is/protocol";
import {
createImageAssetFixture,
@@ -9,7 +10,7 @@ import {
} from "@webstudio-is/protocol/fixtures";
import { createFileIfNotExists, isFileExists } from "../fs-utils";
import { resolveApiConnection } from "../api-connection";
import { sync } from "./sync";
import { sync, defaultSyncDependencies } from "./sync";
import { apiCompatibilityHeaders } from "./api";
import { materializeManagedAgents } from "../managed-agents";
@@ -22,6 +23,7 @@ const indicator = {
};
const loadProjectBundleByBuildId = vi.fn();
const loadProjectBundleByProjectId = vi.fn();
const loadCurrentProjectBundle = vi.fn();
const downloadAssetFiles = vi.fn();
const dependencies = {
createFileIfNotExists,
@@ -29,6 +31,7 @@ const dependencies = {
isFileExists,
loadProjectBundleByBuildId,
loadProjectBundleByProjectId,
loadCurrentProjectBundle,
readFile,
resolveApiConnection,
spinner: () => indicator,
@@ -48,6 +51,7 @@ beforeEach(async () => {
process.chdir(tempDir);
loadProjectBundleByBuildId.mockResolvedValue(createProjectBundle());
loadProjectBundleByProjectId.mockResolvedValue(createProjectBundle());
loadCurrentProjectBundle.mockResolvedValue(createProjectBundle());
downloadAssetFiles.mockResolvedValue(undefined);
indicator.start.mockClear();
indicator.message.mockClear();
@@ -227,7 +231,7 @@ test("sends linked share token when synchronizing by build id", async () => {
});
});
test("explains unpublished project bundle errors when synchronizing linked project", async () => {
test("syncs current editable data without requiring a published project", async () => {
loadProjectBundleByProjectId.mockRejectedValue(
Object.assign(new Error("Not published"), {
data: { code: "NOT_FOUND", webstudioCode: "PROJECT_NOT_PUBLISHED" },
@@ -239,19 +243,22 @@ test("explains unpublished project bundle errors when synchronizing linked proje
projectId: "project-id",
}));
await expect(
sync({}, { ...dependencies, resolveApiConnection })
).rejects.toThrow("Handled CLI error");
expect(indicator.stop).toHaveBeenCalledWith(
[
"Unable to synchronize project bundle because the project is not published.",
"`webstudio sync` downloads the published project bundle.",
"Publishing is not required for MCP editing. Do not ask the user to publish; use MCP tools against the latest editable build.",
"For visual verification, use `preview.start` or `webstudio preview --source session` instead.",
].join("\n"),
2
);
const current = createProjectBundle({
build: { version: 438 },
assets: [createImageAssetFixture()],
});
loadCurrentProjectBundle.mockResolvedValue(current);
await sync({}, { ...dependencies, resolveApiConnection });
expect(loadProjectBundleByProjectId).not.toHaveBeenCalled();
expect(loadCurrentProjectBundle).toHaveBeenCalledWith({
authToken: "share-token",
origin: "https://example.com",
projectId: "project-id",
headers: apiCompatibilityHeaders,
});
const data = JSON.parse(await readFile(".webstudio/data.json", "utf8"));
expect(data.build.version).toBe(438);
expect(data.assets).toEqual(current.assets);
});
test("explains unpublished project bundle errors when synchronizing by build id", async () => {
@@ -274,10 +281,8 @@ test("explains unpublished project bundle errors when synchronizing by build id"
expect(indicator.stop).toHaveBeenCalledWith(
[
"Unable to synchronize project bundle because the project is not published.",
"`webstudio sync` downloads the published project bundle.",
"Publishing is not required for MCP editing. Do not ask the user to publish; use MCP tools against the latest editable build.",
"For visual verification, use `preview.start` or `webstudio preview --source session` instead.",
"The selected build cannot be exported.",
"Run `webstudio sync` without --buildId to export the current saved project without publishing.",
].join("\n"),
2
);
@@ -319,3 +324,102 @@ test("throws handled error when local project config is missing", async () => {
2
);
});
test("repeated sync preserves current build metadata and file formats without publishing", async () => {
const file = {
...createImageAssetFixture(),
type: "file" as const,
name: "post.mdx",
filename: "post",
format: "mdx",
meta: {},
};
const current = createProjectBundle({
assets: [file],
build: {
version: 438,
createdAt: "2026-07-30T23:21:20.362Z",
updatedAt: "2026-09-01T21:44:30.648Z",
},
});
const maps = [
"breakpoints",
"styles",
"styleSources",
"styleSourceSelections",
"props",
"instances",
"dataSources",
"resources",
] as const;
const rawBuild = {
...current.build,
...Object.fromEntries(
maps.map((key) => [key, current.build[key].map(([, value]) => value)])
),
assets: current.assets,
assetFolders: [],
project: {
id: current.build.projectId,
title: current.projectTitle,
domain: current.projectDomain,
},
};
const requests: string[] = [];
const tokens: Array<string | string[] | undefined> = [];
const server = createServer((request, response) => {
const url = new URL(request.url ?? "/", "http://localhost");
requests.push(url.pathname);
if (url.pathname === "/cgi/asset/post.mdx") {
response.setHeader("content-type", "text/mdx");
response.end("# Post");
return;
}
tokens.push(request.headers["x-auth-token"]);
response.setHeader("content-type", "application/json");
if (url.pathname !== "/trpc/build.loadData") {
response.writeHead(404);
response.end(JSON.stringify({ error: "unexpected endpoint" }));
return;
}
response.end(JSON.stringify([{ result: { data: rawBuild } }]));
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
try {
const address = server.address();
if (address === null || typeof address === "string") {
throw new Error("Missing test server address");
}
const connection = {
origin: `http://127.0.0.1:${address.port}`,
projectId: current.build.projectId,
authToken: "source-token",
};
const realDependencies = {
...defaultSyncDependencies,
resolveApiConnection: async () => connection,
spinner: () => indicator,
};
await sync({}, realDependencies);
const first = await readFile(".webstudio/data.json", "utf8");
await sync({}, realDependencies);
expect(await readFile(".webstudio/data.json", "utf8")).toBe(first);
expect(JSON.parse(first)).toMatchObject({
build: current.build,
projectTitle: current.projectTitle,
projectDomain: current.projectDomain,
assets: [file],
});
expect(await readFile(".webstudio/assets/post.mdx", "utf8")).toBe("# Post");
expect(requests).toEqual([
"/trpc/build.loadData",
"/cgi/asset/post.mdx",
"/trpc/build.loadData",
]);
expect(tokens).toEqual(["source-token", "source-token"]);
} finally {
await new Promise<void>((resolve, reject) =>
server.close((error) => (error ? reject(error) : resolve()))
);
}
});
+101 -9
View File
@@ -2,11 +2,15 @@ import { readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { cwd } from "node:process";
import { spinner } from "@clack/prompts";
import { type PublishedProjectBundle } from "@webstudio-is/protocol";
import {
bundleVersion,
publishedProjectBundle,
type PublishedProjectBundle,
} from "@webstudio-is/protocol";
import {
getApiErrorCode,
loadProjectBundleByBuildId,
loadProjectBundleByProjectId,
loadBuilderDataByProjectId,
toLocalProjectBundle,
} from "@webstudio-is/http-client";
import { createFileIfNotExists, isFileExists } from "../fs-utils";
@@ -20,13 +24,77 @@ import { apiCompatibilityHeaders, stopSpinnerWithError } from "./api";
import { downloadAssetFiles } from "../asset-files";
import { resolveApiConnection } from "../api-connection";
import { materializeManagedAgents } from "../managed-agents";
import { serializedBuild } from "@webstudio-is/project-build/contracts";
import {
createBuilderStateFromBuildData,
createSerializedBuilderBuildDataFromState,
} from "@webstudio-is/project-build/state";
import { migratePages } from "@webstudio-is/project-migrations/pages";
import {
createReachableAssetContentCompilationPlan,
getHomePage,
} from "@webstudio-is/sdk";
import { compileContentSource } from "@webstudio-is/content-engine/compiler";
import { parseContentDatabaseMaxBytes } from "@webstudio-is/content-engine";
import { createFileSystemContentSource } from "../filesystem-content-source";
import { z } from "zod";
import {
breakpoint,
styleDecl,
styleSource,
styleSourceSelection,
prop,
instance,
dataSource,
resource,
} from "@webstudio-is/sdk/schema";
// Builder data uses arrays rather than the keyed pairs in an export bundle.
const builderData = serializedBuild.extend({
breakpoints: breakpoint.array(),
styles: styleDecl.array(),
styleSources: styleSource.array(),
styleSourceSelections: styleSourceSelection.array(),
props: prop.array(),
instances: instance.array(),
dataSources: dataSource.array(),
resources: resource.array(),
assets: publishedProjectBundle.shape.assets,
assetFolders: publishedProjectBundle.shape.assetFolders,
project: z.object({ id: z.string(), title: z.string(), domain: z.string() }),
});
const loadCurrentProjectBundle = async (
connection: Awaited<ReturnType<typeof resolveApiConnection>>
) => {
const data = builderData.parse(await loadBuilderDataByProjectId(connection));
if (
data.projectId !== connection.projectId ||
data.project.id !== connection.projectId
) {
throw new Error("Source project does not match the linked project");
}
const pages = migratePages(data.pages);
const state = createBuilderStateFromBuildData({ ...data, pages });
return publishedProjectBundle.parse({
bundleVersion,
origin: connection.origin,
projectDomain: data.project.domain,
projectTitle: data.project.title,
page: getHomePage(pages),
pages: Array.from(pages.pages.values()),
assets: data.assets,
assetFolders: data.assetFolders,
build: { ...data, ...createSerializedBuilderBuildDataFromState(state) },
});
};
export type SyncDependencies = {
createFileIfNotExists: typeof createFileIfNotExists;
downloadAssetFiles: typeof downloadAssetFiles;
isFileExists: typeof isFileExists;
loadProjectBundleByBuildId: typeof loadProjectBundleByBuildId;
loadProjectBundleByProjectId: typeof loadProjectBundleByProjectId;
loadCurrentProjectBundle: typeof loadCurrentProjectBundle;
readFile: typeof readFile;
resolveApiConnection: typeof resolveApiConnection;
spinner: typeof spinner;
@@ -39,7 +107,7 @@ export const defaultSyncDependencies: SyncDependencies = {
downloadAssetFiles,
isFileExists,
loadProjectBundleByBuildId,
loadProjectBundleByProjectId,
loadCurrentProjectBundle,
readFile,
resolveApiConnection,
spinner,
@@ -65,10 +133,8 @@ export const syncOptions = (yargs: CommonYargsArgv) =>
type SyncOptions = Partial<StrictYargsOptionsToInterface<typeof syncOptions>>;
const unpublishedProjectBundleMessage = [
"Unable to synchronize project bundle because the project is not published.",
"`webstudio sync` downloads the published project bundle.",
"Publishing is not required for MCP editing. Do not ask the user to publish; use MCP tools against the latest editable build.",
"For visual verification, use `preview.start` or `webstudio preview --source session` instead.",
"The selected build cannot be exported.",
"Run `webstudio sync` without --buildId to export the current saved project without publishing.",
].join("\n");
const isUnpublishedProjectBundleError = (error: unknown) => {
@@ -146,7 +212,7 @@ export const sync = async (
origin,
headers: apiCompatibilityHeaders,
})
: await dependencies.loadProjectBundleByProjectId({
: await dependencies.loadCurrentProjectBundle({
projectId,
authToken,
origin,
@@ -185,6 +251,32 @@ export const sync = async (
}
}
if (options.buildId === undefined) {
const plan = createReachableAssetContentCompilationPlan({
props: project.build.props.map(([, value]) => value),
dataSources: project.build.dataSources.map(([, value]) => value),
resources: project.build.resources.map(([, value]) => value),
});
if (plan !== undefined) {
syncing.message("Preparing local content index");
const { artifact } = await compileContentSource({
source: createFileSystemContentSource({
projectId: project.build.projectId,
assets: project.assets,
folders: new Map(
(project.assetFolders ?? []).map((folder) => [folder.id, folder])
),
}),
projectId: project.build.projectId,
plan,
maxBytes: parseContentDatabaseMaxBytes(
process.env.CONTENT_DATABASE_MAX_BYTES
),
});
project.assetIndex = artifact;
}
}
const localBuildFilePath = join(cwd(), LOCAL_DATA_FILE);
await dependencies.createFileIfNotExists(localBuildFilePath);
await dependencies.writeFile(
+185 -140
View File
@@ -2183,148 +2183,193 @@ test("imports project bundle through staged upload", async () => {
expect(JSON.stringify(trpcBody)).not.toContain("largeContent");
});
test("imports project bundle with assets and retries missing asset uploads", async () => {
const asset = createImageAssetFixture({ name: "image.png" });
const bundle = createPublishedProjectBundleFixture({ assets: [asset] });
const calls: string[] = [];
const uploadUrls: string[] = [];
const uploadOffsets = new Map<string, number>();
let importAttempts = 0;
let uploadAttempts = 0;
const server = createServer(async (request, response) => {
const url = new URL(request.url ?? "/", "http://localhost");
calls.push(`${request.method} ${url.pathname}`);
if (
request.method === "GET" &&
url.pathname === "/trpc/build.checkProjectBuildPermission"
) {
response.writeHead(200, { "content-type": "application/json" });
response.end(JSON.stringify([{ result: { data: undefined } }]));
return;
}
if (
request.method === "POST" &&
url.pathname === "/rest/assets/uploads/image.png"
) {
uploadAttempts += 1;
uploadUrls.push(url.href);
await readRequestBody(request);
response.writeHead(200, { "content-type": "application/json" });
response.end(
JSON.stringify({
uploadedAssets: [
createImageAssetFixture({
id: `server-generated-asset-${uploadAttempts}`,
name: "image_destination.png",
}),
],
deduplicated: false,
})
);
return;
}
if (request.method === "POST" && url.pathname === stagedUploadPath) {
response.writeHead(201, {
Location: `http://${request.headers.host}${stagedUploadPath}/upload-id-${importAttempts}`,
"Tus-Resumable": "1.0.0",
});
response.end();
return;
}
if (
request.method === "PATCH" &&
url.pathname.startsWith(`${stagedUploadPath}/upload-id-`)
) {
const chunk = await readRequestBody(request);
const offset = (uploadOffsets.get(url.pathname) ?? 0) + chunk.byteLength;
uploadOffsets.set(url.pathname, offset);
response.writeHead(204, {
"Tus-Resumable": "1.0.0",
"Upload-Offset": String(offset),
});
response.end();
return;
}
if (
request.method === "POST" &&
url.pathname === "/trpc/build.importProjectBundle"
) {
importAttempts += 1;
await readRequestBody(request);
response.writeHead(200, { "content-type": "application/json" });
response.end(
JSON.stringify([
importAttempts === 1
? {
error: {
message: "Imported asset files are missing: image.png",
code: -32603,
data: { code: "INTERNAL_SERVER_ERROR" },
},
}
: { result: { data: { version: 2 } } },
])
);
return;
}
response.writeHead(404);
response.end();
});
await new Promise<void>((resolve) => {
server.listen(0, "127.0.0.1", resolve);
});
const importAttemptMessages: string[] = [];
const missingAssetMessages: string[] = [];
const uploadAssetMessages: string[] = [];
try {
const address = server.address();
if (address === null || typeof address === "string") {
throw new Error("Server address is unavailable");
}
await expect(
importProjectBundleWithAssets({
authToken: "token",
origin: `http://127.0.0.1:${address.port}`,
projectId: "project-id",
data: bundle,
readAssetData: async () => new Blob(["asset"]),
onImportAttempt: () => importAttemptMessages.push("attempt"),
onMissingAssets: (assets) =>
missingAssetMessages.push(
assets.map((asset) => asset.name).join(",")
),
onUploadAssets: (assets) =>
uploadAssetMessages.push(assets.map((asset) => asset.name).join(",")),
})
).resolves.toEqual({ version: 2 });
} finally {
await new Promise<void>((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
test.each([undefined, "source-folder"])(
"imports assets from folder %s and preserves their final placement",
async (folderId) => {
const asset = createImageAssetFixture({ name: "image.png", folderId });
const assetFolders =
folderId === undefined
? []
: [
{
id: folderId,
name: "Images",
projectId: "source-project",
createdAt: "2026-09-09T00:00:00.000Z",
},
];
const bundle = createPublishedProjectBundleFixture({
assets: [asset],
assetFolders,
});
}
const uploadChunks = new Map<string, Buffer[]>();
const readAssets: unknown[] = [];
const calls: string[] = [];
const uploadUrls: string[] = [];
const uploadOffsets = new Map<string, number>();
let importAttempts = 0;
let uploadAttempts = 0;
expect(calls).toContain("GET /trpc/build.checkProjectBuildPermission");
expect(importAttempts).toBe(2);
expect(uploadAttempts).toBe(1);
expect(uploadUrls.every((url) => url.includes("assetId=") === false)).toBe(
true
);
expect(uploadOffsets.size).toBe(2);
expect(importAttemptMessages).toEqual(["attempt", "attempt"]);
expect(missingAssetMessages).toEqual([]);
expect(uploadAssetMessages).toEqual(["image.png"]);
});
const server = createServer(async (request, response) => {
const url = new URL(request.url ?? "/", "http://localhost");
calls.push(`${request.method} ${url.pathname}`);
if (
request.method === "GET" &&
url.pathname === "/trpc/build.checkProjectBuildPermission"
) {
response.writeHead(200, { "content-type": "application/json" });
response.end(JSON.stringify([{ result: { data: undefined } }]));
return;
}
if (
request.method === "POST" &&
url.pathname === "/rest/assets/uploads/image.png"
) {
uploadAttempts += 1;
uploadUrls.push(url.href);
await readRequestBody(request);
response.writeHead(200, { "content-type": "application/json" });
response.end(
JSON.stringify({
uploadedAssets: [
createImageAssetFixture({
id: `server-generated-asset-${uploadAttempts}`,
name: "image_destination.png",
}),
],
deduplicated: false,
})
);
return;
}
if (request.method === "POST" && url.pathname === stagedUploadPath) {
response.writeHead(201, {
Location: `http://${request.headers.host}${stagedUploadPath}/upload-id-${importAttempts}`,
"Tus-Resumable": "1.0.0",
});
response.end();
return;
}
if (
request.method === "PATCH" &&
url.pathname.startsWith(`${stagedUploadPath}/upload-id-`)
) {
const chunk = await readRequestBody(request);
const chunks = uploadChunks.get(url.pathname) ?? [];
chunks.push(chunk);
uploadChunks.set(url.pathname, chunks);
const offset =
(uploadOffsets.get(url.pathname) ?? 0) + chunk.byteLength;
uploadOffsets.set(url.pathname, offset);
response.writeHead(204, {
"Tus-Resumable": "1.0.0",
"Upload-Offset": String(offset),
});
response.end();
return;
}
if (
request.method === "POST" &&
url.pathname === "/trpc/build.importProjectBundle"
) {
importAttempts += 1;
await readRequestBody(request);
response.writeHead(200, { "content-type": "application/json" });
response.end(
JSON.stringify([
importAttempts === 1
? {
error: {
message: "Imported asset files are missing: image.png",
code: -32603,
data: { code: "INTERNAL_SERVER_ERROR" },
},
}
: { result: { data: { version: 2 } } },
])
);
return;
}
response.writeHead(404);
response.end();
});
await new Promise<void>((resolve) => {
server.listen(0, "127.0.0.1", resolve);
});
const importAttemptMessages: string[] = [];
const missingAssetMessages: string[] = [];
const uploadAssetMessages: string[] = [];
try {
const address = server.address();
if (address === null || typeof address === "string") {
throw new Error("Server address is unavailable");
}
await expect(
importProjectBundleWithAssets({
authToken: "token",
origin: `http://127.0.0.1:${address.port}`,
projectId: "project-id",
data: bundle,
readAssetData: async (asset) => {
readAssets.push(asset);
return new Blob(["asset"]);
},
onImportAttempt: () => importAttemptMessages.push("attempt"),
onMissingAssets: (assets) =>
missingAssetMessages.push(
assets.map((asset) => asset.name).join(",")
),
onUploadAssets: (assets) =>
uploadAssetMessages.push(
assets.map((asset) => asset.name).join(",")
),
})
).resolves.toEqual({ version: 2 });
} finally {
await new Promise<void>((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
}
expect(calls).toContain("GET /trpc/build.checkProjectBuildPermission");
expect(importAttempts).toBe(2);
expect(uploadAttempts).toBe(1);
expect(
uploadUrls.map((url) => new URL(url).searchParams.get("folderId"))
).toEqual([null]);
expect(readAssets).toHaveLength(1);
expect(readAssets[0]).toBe(asset);
expect(bundle.assets[0]).toEqual(asset);
const importedBundles = Array.from(uploadChunks.values(), (chunks) =>
JSON.parse(Buffer.concat(chunks).toString("utf8"))
);
expect(importedBundles.map((bundle) => bundle.assetFolders)).toEqual([
assetFolders,
assetFolders,
]);
expect(importedBundles.map((bundle) => bundle.assets)).toEqual([
[asset],
[{ ...asset, name: "image_destination.png" }],
]);
expect(uploadUrls.every((url) => url.includes("assetId=") === false)).toBe(
true
);
expect(uploadOffsets.size).toBe(2);
expect(importAttemptMessages).toEqual(["attempt", "attempt"]);
expect(missingAssetMessages).toEqual([]);
expect(uploadAssetMessages).toEqual(["image.png"]);
}
);
test("rejects project bundles over the import size limit", async () => {
let requestCount = 0;
+16 -2
View File
@@ -1271,6 +1271,11 @@ export const getProjectInfo = projectQuery(
getPublicApiOperationPath("inspect")
);
export const loadBuilderDataByProjectId = (params: AuthProjectParams) =>
createAuthTrpcClient(params).query("build.loadData", {
projectId: params.projectId,
});
export const getProjectPermissions = projectQuery(
getPublicApiOperationPath("permissions")
);
@@ -2418,10 +2423,19 @@ export const importProjectBundleWithAssets = async (
} else {
params.onMissingAssets?.(missingAssets);
}
const originalAssetsById = new Map(
missingAssets.map((asset) => [asset.id, asset])
);
const uploadedAssets = await uploadAssets({
...params,
assets: missingAssets,
readAssetData,
// Source folders do not exist in the destination yet. The final
// bundle import creates them and restores the original assignments.
assets: missingAssets.map((asset) => ({
...asset,
folderId: undefined,
})),
readAssetData: (asset) =>
readAssetData(originalAssetsById.get(asset.id)!),
});
if (uploadedAssets.length !== missingAssets.length) {
throw new Error(