mirror of
https://github.com/webstudio-is/webstudio.git
synced 2026-10-29 16:17:39 +09:00
## What changed - Preserve the SSG asset-resource template as a package-relative file URL in the bundled CLI. - Ignore Assets queries that do not bind any parameters from the current dynamic SSG route, while retaining the error for partial or split multi-parameter bindings. - Recognize the persisted `$ws$system.params.*` expression form when enumerating dynamic SSG paths. - Add a required package smoke test that deploys the release-shaped SSG package, runs the packaged CLI, runs Vite and Vike, and verifies rendered Assets data. ## Why Two gaps caused the staging export failures: 1. Vite inlined the template URL as a `data:` URL, which Node could not pass to `readFile`. 2. Dynamic-route validation treated an unrelated Assets query as an incomplete route enumerator. The initial smoke test only checked that an HTML file existed and could pass while resource loading logged a `400`. The replacement test verifies the rendered query result and fails on resource-loading errors. ## Technical choices - The package smoke uses `createPublishedProjectBundleFixture`, so fixture data stays checked against the published bundle contract. - Persisted expressions use `encodeDataSourceVariable`; the generated query now receives the real route parameter instead of compiling to `undefined`. - Temporary deployment, cleanup, subprocess execution, and assertions live in `test:ssg-package`. The workflow invokes one repository command. - Asset downloads are disabled to keep the smoke deterministic. It still covers CLI packaging, the embedded Assets index, dynamic path enumeration, generated resource execution, Vite compilation, and Vike prerender. - The test asserts the queried title in `/posts/hello-world`, rejects any resource-load error, and verifies that an unrelated query does not invent a dynamic path. ## Todo - [x] Reproduce the invalid URL scheme through a bundled template reader. - [x] Preserve the package-relative template URL in the production CLI bundle. - [x] Ignore Assets queries unrelated to the current dynamic route. - [x] Preserve rejection of incomplete multi-parameter route queries. - [x] Support persisted system-variable expressions during route enumeration. - [x] Add a production-shaped packaged CLI and Vike smoke test. - [x] Make the packaged SSG smoke test required on `main`. - [x] Run CLI tests, typecheck, lint, build, and the packaged smoke test. - [x] Rerun the previously failing `WEBSTUDIO-LANDING` SaaS SSG workflow. - [x] Review documentation impact; no user-facing documentation changes are needed. - [x] Review fixture impact; synced fixture inputs and generated outputs are unaffected because smoke data exists only in a temporary deployment. ## Verification - `pnpm --filter webstudio test` — 61 files, 977 tests passed before the final test addition - `pnpm --filter webstudio test -- prebuild.test.ts ssg-asset-resource-fetch-template.test.ts` — 57 tests passed - `pnpm --filter webstudio test:ssg-package` - `pnpm --filter webstudio typecheck` - `pnpm lint` - `pnpm --filter webstudio build` - Package regression proof: the smoke failed on the original unrelated-query implementation with `Dynamic SSG route parameters must be completely enumerated by one Assets query`, then passed with the fix restored. - Persisted-expression regression proof: the focused test returned no prerender path before encoded-system support, then passed with the support restored. - Template regression proof: the focused bundle test failed with `ERR_INVALID_URL_SCHEME` before the fix and passed after it. - Exact SaaS E2E rerun passed: project sync, CLI build, Vike build, archive upload, and database update ([run 31519515809](https://github.com/webstudio-is/webstudio-saas/actions/runs/31519515809)). Closes #6100 Supersedes #6101 after moving the change to the staging branch.
257 lines
6.5 KiB
TypeScript
257 lines
6.5 KiB
TypeScript
import { execFile } from "node:child_process";
|
|
import assert from "node:assert/strict";
|
|
import {
|
|
mkdtemp,
|
|
mkdir,
|
|
readFile,
|
|
rm,
|
|
stat,
|
|
writeFile,
|
|
} from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { promisify } from "node:util";
|
|
import type {
|
|
Instance,
|
|
Page,
|
|
Resource,
|
|
StructuredAssetQueryFilterBinding,
|
|
} from "@webstudio-is/sdk";
|
|
import {
|
|
createStructuredAssetQueryResourceBody,
|
|
encodeDataSourceVariable,
|
|
SYSTEM_VARIABLE_ID,
|
|
} from "@webstudio-is/sdk";
|
|
import type { AssetFileDocument } from "@webstudio-is/content-engine";
|
|
import {
|
|
createAssetIndex,
|
|
createCanonicalAssetFileEntry,
|
|
} from "@webstudio-is/content-engine/compiler";
|
|
import {
|
|
createPageFixture,
|
|
createPublishedProjectBundleFixture,
|
|
} from "@webstudio-is/protocol/fixtures";
|
|
import type { PublishedProjectBundle } from "@webstudio-is/protocol";
|
|
|
|
const execFileAsync = promisify(execFile);
|
|
const repositoryRoot = join(import.meta.dirname, "../../..");
|
|
const projectId = "ssg-assets-smoke";
|
|
const postDataSourceId = "post-data";
|
|
const systemExpression = encodeDataSourceVariable(SYSTEM_VARIABLE_ID);
|
|
const renderedTitle = "Hello from packaged Assets";
|
|
|
|
const run = async (args: string[], cwd = repositoryRoot) => {
|
|
try {
|
|
return await execFileAsync("pnpm", args, {
|
|
cwd,
|
|
maxBuffer: 50 * 1024 * 1024,
|
|
});
|
|
} catch (error) {
|
|
if (error instanceof Error && "stdout" in error && "stderr" in error) {
|
|
error.message += `\n${String(error.stdout)}\n${String(error.stderr)}`;
|
|
}
|
|
throw error;
|
|
}
|
|
};
|
|
|
|
const createAssetsResource = ({
|
|
id,
|
|
filter,
|
|
result = "many",
|
|
}: {
|
|
id: string;
|
|
filter: StructuredAssetQueryFilterBinding;
|
|
result?: "many" | "one";
|
|
}): Resource => ({
|
|
id,
|
|
name: id,
|
|
control: "system",
|
|
method: "post",
|
|
url: '"/$resources/assets"',
|
|
headers: [],
|
|
body: createStructuredAssetQueryResourceBody({
|
|
result,
|
|
where: { all: [filter] },
|
|
sort: [],
|
|
limit: result === "one" ? "1" : "20",
|
|
offset: "0",
|
|
output: { mode: "all", includeMetadata: false },
|
|
content: { mode: "none" },
|
|
}),
|
|
});
|
|
|
|
const createFixture = async (): Promise<PublishedProjectBundle> => {
|
|
const document: AssetFileDocument = {
|
|
_id: "post-1",
|
|
_type: "asset.file",
|
|
name: "hello-world.md",
|
|
path: "blog/hello-world.md",
|
|
key: "hello-world",
|
|
extension: "md",
|
|
mimeType: "text/markdown",
|
|
size: 13,
|
|
revision: "post-1-revision",
|
|
contentRef: "hello-world.md",
|
|
properties: {
|
|
slug: "hello-world",
|
|
title: renderedTitle,
|
|
draft: false,
|
|
},
|
|
};
|
|
const assetIndex = await createAssetIndex({
|
|
projectId,
|
|
entries: [createCanonicalAssetFileEntry({ projectId, document })],
|
|
});
|
|
const pages: Page[] = [
|
|
createPageFixture({ rootInstanceId: "home-root" }),
|
|
createPageFixture({
|
|
id: "unrelated-query",
|
|
name: "Unrelated query",
|
|
title: "Unrelated query",
|
|
path: "/unrelated/:slug",
|
|
rootInstanceId: "unrelated-root",
|
|
}),
|
|
createPageFixture({
|
|
id: "post",
|
|
name: "Post",
|
|
title: `${encodeDataSourceVariable(postDataSourceId)}.data?.properties?.title ?? "Missing post"`,
|
|
path: "/posts/:slug",
|
|
rootInstanceId: "post-root",
|
|
}),
|
|
];
|
|
const instances: PublishedProjectBundle["build"]["instances"] = pages.map(
|
|
({ rootInstanceId }) => [
|
|
rootInstanceId,
|
|
{
|
|
type: "instance",
|
|
id: rootInstanceId,
|
|
component: "Box",
|
|
children: [],
|
|
} satisfies Instance,
|
|
]
|
|
);
|
|
const unrelatedResource = createAssetsResource({
|
|
id: "unrelated-posts",
|
|
filter: {
|
|
field: ["properties", "draft"],
|
|
operator: "eq",
|
|
value: "false",
|
|
},
|
|
});
|
|
const postResource = createAssetsResource({
|
|
id: "post",
|
|
result: "one",
|
|
filter: {
|
|
field: ["properties", "slug"],
|
|
operator: "eq",
|
|
value: `${systemExpression}.params.slug`,
|
|
},
|
|
});
|
|
|
|
return createPublishedProjectBundleFixture({
|
|
projectDomain: "example.com",
|
|
projectTitle: "SSG Assets package smoke",
|
|
page: pages[0],
|
|
pages,
|
|
assetIndex,
|
|
buildPages: {
|
|
homePageId: pages[0].id,
|
|
rootFolderId: "root",
|
|
pages,
|
|
folders: [
|
|
{
|
|
id: "root",
|
|
name: "Root",
|
|
slug: "",
|
|
children: pages.map(({ id }) => id),
|
|
},
|
|
],
|
|
},
|
|
build: {
|
|
projectId,
|
|
instances,
|
|
dataSources: [
|
|
[
|
|
"unrelated-posts-data",
|
|
{
|
|
id: "unrelated-posts-data",
|
|
type: "resource",
|
|
name: "unrelatedPosts",
|
|
resourceId: unrelatedResource.id,
|
|
scopeInstanceId: "unrelated-root",
|
|
},
|
|
],
|
|
[
|
|
postDataSourceId,
|
|
{
|
|
id: postDataSourceId,
|
|
type: "resource",
|
|
name: "post",
|
|
resourceId: postResource.id,
|
|
scopeInstanceId: "post-root",
|
|
},
|
|
],
|
|
],
|
|
resources: [
|
|
[unrelatedResource.id, unrelatedResource],
|
|
[postResource.id, postResource],
|
|
],
|
|
},
|
|
});
|
|
};
|
|
|
|
const fixtureDirectory = await mkdtemp(
|
|
join(tmpdir(), "webstudio-ssg-package-")
|
|
);
|
|
|
|
try {
|
|
await run(["--filter", "ssg-cloudflare-pages^...", "run", "build"]);
|
|
await run(["--filter", "ssg-cloudflare-pages", "deploy", fixtureDirectory]);
|
|
await rm(join(fixtureDirectory, "pages"), { recursive: true, force: true });
|
|
await mkdir(join(fixtureDirectory, ".webstudio"), { recursive: true });
|
|
await writeFile(
|
|
join(fixtureDirectory, ".webstudio", "data.json"),
|
|
JSON.stringify(await createFixture()),
|
|
"utf8"
|
|
);
|
|
|
|
const cliBuild = await run(
|
|
[
|
|
"-C",
|
|
fixtureDirectory,
|
|
"exec",
|
|
"webstudio",
|
|
"build",
|
|
"--no-assets",
|
|
"--template",
|
|
"ssg",
|
|
"--template",
|
|
"internal",
|
|
],
|
|
fixtureDirectory
|
|
);
|
|
const siteBuild = await run(["-C", fixtureDirectory, "build"]);
|
|
const output = `${cliBuild.stdout}\n${cliBuild.stderr}\n${siteBuild.stdout}\n${siteBuild.stderr}`;
|
|
assert.doesNotMatch(output, /Failed to load resource request/);
|
|
|
|
const html = await readFile(
|
|
join(
|
|
fixtureDirectory,
|
|
"dist",
|
|
"client",
|
|
"posts",
|
|
"hello-world",
|
|
"index.html"
|
|
),
|
|
"utf8"
|
|
);
|
|
assert.match(html, new RegExp(`<title>${renderedTitle}</title>`));
|
|
await assert.rejects(
|
|
stat(join(fixtureDirectory, "dist", "client", "unrelated")),
|
|
{ code: "ENOENT" },
|
|
"An unrelated Assets query produced a dynamic SSG path."
|
|
);
|
|
} finally {
|
|
await rm(fixtureDirectory, { recursive: true, force: true });
|
|
}
|