Skip to content

Commit a1e163e

Browse files
committed
Merge branch 'main' into release-30-06-2026.staging
2 parents f970e5b + 6ea83ef commit a1e163e

14 files changed

Lines changed: 292 additions & 9 deletions

File tree

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { describe, expect, test } from "vitest";
2+
import type { DashboardProject } from "@webstudio-is/dashboard";
3+
import { searchProjects } from "./search-results";
4+
5+
const createProject = (
6+
overrides: Partial<DashboardProject>
7+
): DashboardProject =>
8+
({
9+
id: "project-1",
10+
createdAt: "2024-01-01T00:00:00.000Z",
11+
title: "Default Site",
12+
domain: "default",
13+
userId: "user-1",
14+
isDeleted: false,
15+
isPublished: false,
16+
latestBuild: null,
17+
previewImageAsset: null,
18+
previewImageAssetId: null,
19+
latestBuildVirtual: null,
20+
marketplaceApprovalStatus: "UNLISTED",
21+
tags: [],
22+
domainsVirtual: [],
23+
workspaceId: null,
24+
...overrides,
25+
}) as DashboardProject;
26+
27+
describe("searchProjects", () => {
28+
test.each([
29+
["id", "83e97c09dcce", { id: "d845c167-ea07-4875-b08d-83e97c09dcce" }],
30+
["title", "Marketing", { title: "Marketing Site" }],
31+
["domain", "docs", { domain: "docs" }],
32+
[
33+
"custom domain",
34+
"client.example.com",
35+
{
36+
domainsVirtual: [
37+
{ domain: "client.example.com", status: "ACTIVE", verified: true },
38+
],
39+
},
40+
],
41+
[
42+
"latest build id",
43+
"aca9e9574587",
44+
{
45+
latestBuildVirtual: {
46+
buildId: "f565d527-32e7-4731-bc71-aca9e9574587",
47+
projectId: "project-1",
48+
domainsVirtualId: "",
49+
domain: "fixture",
50+
createdAt: "2024-01-01T00:00:00.000Z",
51+
updatedAt: "2024-01-01T00:00:00.000Z",
52+
publishStatus: "PUBLISHED",
53+
},
54+
},
55+
],
56+
] satisfies Array<[string, string, Partial<DashboardProject>]>)(
57+
"matches projects by %s",
58+
(_field, search, overrides) => {
59+
const projects = [
60+
createProject(overrides),
61+
createProject({ id: "project-other", title: "Other Project" }),
62+
];
63+
64+
expect(searchProjects(projects, search)).toEqual([projects[0]]);
65+
}
66+
);
67+
68+
test("does not return projects without matching fields", () => {
69+
const projects = [
70+
createProject({ title: "Marketing Site" }),
71+
createProject({ title: "Other Project" }),
72+
];
73+
74+
expect(searchProjects(projects, "missing")).toEqual([]);
75+
});
76+
});

apps/builder/app/dashboard/search/search-results.tsx

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,22 @@ const initialSearchResults: SearchResults = {
1616
projects: [],
1717
} as const;
1818

19+
const searchProjectKeys = [
20+
"id",
21+
"title",
22+
"domain",
23+
(project: DashboardProject) =>
24+
project.domainsVirtual.map(({ domain }) => domain),
25+
(project: DashboardProject) => project.latestBuildVirtual?.buildId ?? "",
26+
];
27+
28+
export const searchProjects = (
29+
projects: Array<DashboardProject>,
30+
search: string
31+
) => {
32+
return matchSorter(projects, search, { keys: searchProjectKeys });
33+
};
34+
1935
export const SearchResults = (props: DashboardData) => {
2036
const [searchParams] = useSearchParams();
2137
const { projects, publisherHost } = props;
@@ -25,9 +41,8 @@ export const SearchResults = (props: DashboardData) => {
2541
if (!search || !projects) {
2642
return initialSearchResults;
2743
}
28-
const keys = ["title", "domain"];
2944
return {
30-
projects: matchSorter(projects, search, { keys }),
45+
projects: searchProjects(projects, search),
3146
};
3247
}, [projects, search]);
3348

apps/builder/app/routes/trpc.$.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { appRouter } from "~/services/trcp-router.server";
55
import { preventCrossOriginCookie } from "~/services/no-cross-origin-cookie";
66
import { checkCsrf } from "~/services/csrf-session.server";
77
import { getTrpcResponseMeta } from "~/services/trpc-response-meta.server";
8+
import { isCliApiRequest } from "~/services/trpc-request.server";
89

910
const isServiceRequest = (request: Request) => {
1011
return isServiceAuthorization(request.headers.get("Authorization"));
@@ -15,7 +16,10 @@ const isAuthTokenRequest = (request: Request) => {
1516
};
1617

1718
export const action = async ({ request }: ActionFunctionArgs) => {
18-
if (isServiceRequest(request) === false) {
19+
if (
20+
isServiceRequest(request) === false &&
21+
isCliApiRequest(request) === false
22+
) {
1923
preventCrossOriginCookie(request);
2024
if (isAuthTokenRequest(request) === false) {
2125
await checkCsrf(request);
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { describe, expect, test } from "vitest";
2+
import { apiClientHeader } from "@webstudio-is/trpc-interface/api-compatibility";
3+
import { isCliApiRequest } from "./trpc-request.server";
4+
5+
describe("isCliApiRequest", () => {
6+
test("accepts CLI API requests", () => {
7+
const request = new Request("https://webstudio.is/trpc", {
8+
headers: {
9+
[apiClientHeader]: "cli",
10+
},
11+
});
12+
13+
expect(isCliApiRequest(request)).toBe(true);
14+
});
15+
16+
test("rejects browser and unknown API requests", () => {
17+
const browserRequest = new Request("https://webstudio.is/trpc", {
18+
headers: {
19+
[apiClientHeader]: "browser",
20+
},
21+
});
22+
const unknownRequest = new Request("https://webstudio.is/trpc");
23+
24+
expect(isCliApiRequest(browserRequest)).toBe(false);
25+
expect(isCliApiRequest(unknownRequest)).toBe(false);
26+
});
27+
});
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { apiClientHeader } from "@webstudio-is/trpc-interface/api-compatibility";
2+
3+
export const isCliApiRequest = (request: Pick<Request, "headers">) => {
4+
return request.headers.get(apiClientHeader) === "cli";
5+
};

packages/cli/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,6 @@
3636
"@emotion/hash": "^0.9.2",
3737
"@trpc/client": "^10.45.2",
3838
"@webstudio-is/http-client": "workspace:*",
39-
"@webstudio-is/project-build": "workspace:*",
4039
"@webstudio-is/project-migrations": "workspace:*",
4140
"@webstudio-is/protocol": "workspace:*",
4241
"acorn": "^8.14.1",
@@ -76,6 +75,7 @@
7675
"@vitejs/plugin-react": "^4.4.1",
7776
"@webstudio-is/css-engine": "workspace:*",
7877
"@webstudio-is/image": "workspace:*",
78+
"@webstudio-is/project-build": "workspace:*",
7979
"@webstudio-is/react-sdk": "workspace:*",
8080
"@webstudio-is/sdk": "workspace:*",
8181
"@webstudio-is/sdk-components-animation": "workspace:*",

packages/cli/src/commands/mcp.test.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { expect, test, vi } from "vitest";
2-
import { mcpOptions } from "./mcp";
2+
import { builderNamespaces } from "@webstudio-is/project-build/contracts/namespaces";
3+
import { mcpOptions, prepareMcpProjectSession } from "./mcp";
34

45
test("documents MCP stdio startup and discovery tools", () => {
56
const yargs = {
@@ -20,4 +21,19 @@ test("documents MCP stdio startup and discovery tools", () => {
2021
expect(yargs.epilogue).toHaveBeenCalledWith(
2122
expect.stringContaining("tools/list")
2223
);
24+
expect(yargs.epilogue).toHaveBeenCalledWith(
25+
expect.stringContaining("current Builder dev build")
26+
);
27+
});
28+
29+
test("marks cached namespaces stale before serving MCP tools", async () => {
30+
const session = {
31+
initialize: vi.fn(async () => undefined),
32+
markStale: vi.fn(async () => undefined),
33+
};
34+
35+
await prepareMcpProjectSession(session);
36+
37+
expect(session.initialize).toHaveBeenCalled();
38+
expect(session.markStale).toHaveBeenCalledWith(builderNamespaces);
2339
});

packages/cli/src/commands/mcp.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@ import {
44
connectProjectSessionMcpServer,
55
createMcpStdioTransport,
66
} from "@webstudio-is/project-build/mcp";
7+
import {
8+
builderNamespaces,
9+
type BuilderNamespace,
10+
} from "@webstudio-is/project-build/contracts/namespaces";
711
import { diffPngFiles } from "@webstudio-is/project-build/visual/screenshot-diff";
812
import { publicApiOperations } from "@webstudio-is/protocol";
913
import { importProjectBundleWithAssets } from "@webstudio-is/http-client";
@@ -27,6 +31,18 @@ import { apiCompatibilityHeaders } from "./api";
2731
import { importProject as importProjectCommand } from "./import";
2832
import type { CommonYargsArgv } from "./yargs-types";
2933

34+
type StartableProjectSession = {
35+
initialize: () => Promise<unknown>;
36+
markStale: (namespaces: readonly BuilderNamespace[]) => Promise<unknown>;
37+
};
38+
39+
export const prepareMcpProjectSession = async (
40+
session: StartableProjectSession
41+
) => {
42+
await session.initialize();
43+
await session.markStale(builderNamespaces);
44+
};
45+
3046
export const mcpOptions = (yargs: CommonYargsArgv) =>
3147
yargs
3248
.example(
@@ -44,6 +60,7 @@ export const mcpOptions = (yargs: CommonYargsArgv) =>
4460
.epilogue(
4561
[
4662
"Plain `webstudio mcp` starts the stdio MCP server.",
63+
"Startup marks cached ProjectSession data stale so MCP tools read the current Builder dev build.",
4764
"After startup, MCP clients discover capabilities with tools/list, resources/list, meta.index, meta.guide, and meta.get_more_tools.",
4865
"stdout is reserved for MCP JSON-RPC messages while the server is running.",
4966
].join("\n")
@@ -66,6 +83,7 @@ export const mcp = async () => {
6683
},
6784
};
6885
const session = createCliProjectSession({ connection: apiConnection });
86+
await prepareMcpProjectSession(session);
6987
const preview = createPreviewController({ host: "127.0.0.1", port: 5173 });
7088
await connectProjectSessionMcpServer({
7189
operations: publicApiOperations,

packages/cli/src/commands/sync.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
import { createFileIfNotExists, isFileExists } from "../fs-utils";
1111
import { resolveApiConnection } from "../api-connection";
1212
import { sync } from "./sync";
13+
import { apiCompatibilityHeaders } from "./api";
1314

1415
const originalCwd = process.cwd();
1516
let tempDir: string;
@@ -138,6 +139,31 @@ test("downloads project bundle asset files into local project bundle", async ()
138139
expect(indicator.message).toHaveBeenCalledWith("Downloading 1 asset files");
139140
});
140141

142+
test("sends linked share token when synchronizing by build id", async () => {
143+
const resolveApiConnection = vi.fn(async () => ({
144+
authToken: "share-token",
145+
origin: "https://example.com",
146+
projectId: "project-id",
147+
}));
148+
149+
await sync(
150+
{
151+
buildId: "build-1",
152+
},
153+
{
154+
...dependencies,
155+
resolveApiConnection,
156+
}
157+
);
158+
159+
expect(loadProjectBundleByBuildId).toHaveBeenCalledWith({
160+
buildId: "build-1",
161+
authToken: "share-token",
162+
origin: "https://example.com",
163+
headers: apiCompatibilityHeaders,
164+
});
165+
});
166+
141167
test("does not write local data when synchronized asset download fails", async () => {
142168
const assets = [createImageAssetFixture()];
143169
loadProjectBundleByBuildId.mockResolvedValue(createProjectBundle({ assets }));

packages/cli/src/prebuild.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,11 @@ import {
55
readdir,
66
readFile,
77
rm,
8+
symlink,
89
writeFile,
910
} from "node:fs/promises";
1011
import { join } from "node:path";
12+
import { pathToFileURL } from "node:url";
1113
import { tmpdir } from "node:os";
1214
import { bundleVersion } from "@webstudio-is/protocol";
1315
import { generateRedirectsModule, prebuild } from "./prebuild";
@@ -20,6 +22,35 @@ const rootFolderId = "root";
2022
const elementComponent = "ws:element";
2123
const slowPrebuildTestTimeout = 15_000;
2224
type Redirects = Array<{ old: string; new: string; status?: "301" | "302" }>;
25+
type GeneratedRouteModule = {
26+
loader: (args: { request: Request }) => Response | Promise<Response>;
27+
};
28+
29+
const importGeneratedRoute = async (path: string) => {
30+
await symlink(join(originalCwd, "node_modules"), "node_modules", "dir");
31+
return (await import(
32+
`${pathToFileURL(join(tempDir, path)).href}?test=${crypto.randomUUID()}`
33+
)) as GeneratedRouteModule;
34+
};
35+
36+
const expectGeneratedRedirectFallback = async (path: string) => {
37+
const routeModule = await importGeneratedRoute(path);
38+
const redirectResponse = await routeModule.loader({
39+
request: new Request("https://example.com/dl.php?filename=file.pdf"),
40+
});
41+
expect(redirectResponse.status).toBe(301);
42+
expect(redirectResponse.headers.get("Location")).toBe("/downloads/file.pdf");
43+
44+
try {
45+
await routeModule.loader({
46+
request: new Request("https://example.com/not-a-redirect"),
47+
});
48+
throw new Error("Expected unmatched request to throw a 404 response.");
49+
} catch (error) {
50+
expect(error).toBeInstanceOf(Response);
51+
expect((error as Response).status).toBe(404);
52+
}
53+
};
2354

2455
const getFilePaths = async (dir: string): Promise<string[]> => {
2556
const entries = await readdir(dir, { withFileTypes: true });
@@ -302,6 +333,7 @@ describe("prebuild", () => {
302333
expect(routeTemplate).toContain("../__generated__/_index.server");
303334
expect(routeTemplate).not.toContain("__CLIENT__");
304335
expect(routeTemplate).not.toContain("__SERVER__");
336+
await expectGeneratedRedirectFallback("app/routes/$.tsx");
305337

306338
await expect(
307339
readFile("app/__generated__/stale.ts", "utf8")

0 commit comments

Comments
 (0)