Files
Oleg Isonen 93fa799cb8 fix: Preserve SSG exports with Assets queries (#6102)
## 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.
2026-08-11 20:56:12 +02:00

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 });
}