Files
Oleg Isonen 18d171212f feat: Resolve asset paths in HTML embeds (#6409)
## Description

- Resolve root-relative script, stylesheet, image, and other asset
attributes in HTML Embed markup.
- Use the same asset-path map in Builder Canvas and generated published
sites.
- Keep SaaS asset proxy URLs deployment-relative instead of hard-coding
the `p-….apps.webstudio.is` project origin.
- Preserve canonical `/cgi/asset/` versus `/cgi/image/` routing and
`?format=raw` behavior in Canvas.

## Regression coverage

Added coverage for SaaS prebuild URLs, the real Builder
asset-map-to-HTML-Embed integration, every supported asset attribute,
HTML character references, and responsive-image `srcset` edge cases.

## Verification

- [x] CLI prebuild suite: 69 passed
- [x] HTML Embed suites: 63 passed
- [x] Full Builder browser suite: 2,646 passed
- [x] Asset URL map tests: 2 passed
- [x] CLI, Builder, React SDK, and project-build typechecks
- [x] Lint and package-boundary checks
- [x] Previous PR CI: 29 successful, 1 skipped

## Code Review

- [x] Self-reviewed
- [x] Added regression and integration tests
2026-09-17 20:38:40 +02:00

472 lines
16 KiB
TypeScript

import * as React from "react";
import ReactDOMServer from "react-dom/server";
import { test, expect, describe, beforeEach } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import { ReactSdkContext } from "@webstudio-is/react-sdk/runtime";
import { __testing__, HtmlEmbed } from "./html-embed";
import { cartesian } from "./test-utils/cartesian";
const { scriptTestIdPrefix } = __testing__;
const SCRIPT_TEST_ID = "script-a";
const SCRIPT_PROCESSED_TEST_ID = `${scriptTestIdPrefix}${SCRIPT_TEST_ID}`;
const FRAGMENT_DIV_ID = "div";
const App = (props: {
clientOnly?: boolean;
renderer?: "canvas" | "preview";
executeScriptOnCanvas?: boolean;
$ws$executeScripts?: boolean;
assetUrlsByPath?: Record<string, string>;
code?: string;
}) => {
const [page, switchPage] = React.useReducer((n) => (n + 1) % 2, 0);
const [refresh, setRefresh] = React.useReducer((n) => n + 1, 0);
const code =
props.code ??
`
<script data-testid="${SCRIPT_TEST_ID}" data-page="${page}">console.log('hello')</script>
<div data-testid="${FRAGMENT_DIV_ID}">hello</div>
`;
return (
<ReactSdkContext.Provider
value={{
assetBaseUrl: "",
assetUrlsByPath: props.assetUrlsByPath,
imageLoader: () => "",
renderer: props.renderer,
resources: {},
breakpoints: [],
onError: console.error,
}}
>
<div key={page}>
<HtmlEmbed
code={code}
clientOnly={props.clientOnly}
executeScriptOnCanvas={props.executeScriptOnCanvas}
$ws$executeScripts={props.$ws$executeScripts}
/>
<button type="button" onClick={switchPage}>
page:{page}
</button>
<button type="button" onClick={setRefresh}>
refresh:{refresh}
</button>
</div>
</ReactSdkContext.Provider>
);
};
beforeEach(() => {
// clear body
for (const child of document.body.children) {
child.remove();
}
});
describe("Published site", () => {
test("resolves root-relative asset paths during server rendering", () => {
const html = ReactDOMServer.renderToString(
<App
code={`
<script src="/test.js?version=1#ready"></script>
<link rel="stylesheet" href="/styles/site.css">
<img src="/Hero image.png">
<img src="/not-an-asset.png">
`}
assetUrlsByPath={{
"/test.js": "/assets/test_hash.js",
"/styles/site.css": "/assets/site_hash.css",
"/Hero%20image.png":
"https://assets.example/hero_hash.png?format=raw",
}}
/>
);
expect(html).toContain('src="/assets/test_hash.js?version=1#ready"');
expect(html).toContain('href="/assets/site_hash.css"');
expect(html).toContain(
'src="https://assets.example/hero_hash.png?format=raw"'
);
expect(html).toContain('src="/not-an-asset.png"');
});
/**
* Tests the behavior of script tags in an SSR context for a published site with `clientOnly` set to false:
* - SSR: Renders script tags in HTML embeds directly, without modification, on the server side, on hydration and on refresh.
* - Page Navigation: Executes scripts using additional processing described here https://ghinda.net/article/script-tags/.
*/
test.each([false, undefined])("clientOnly === false", async (clientOnly) => {
const ui = <App clientOnly={clientOnly} />;
const container = document.createElement("div");
// Server rendering
document.body.appendChild(container);
container.innerHTML = ReactDOMServer.renderToString(ui);
expect(screen.queryByTestId(SCRIPT_TEST_ID)).toBeTruthy();
expect(screen.queryByTestId(FRAGMENT_DIV_ID)).toBeTruthy();
// Hydration
render(ui, { container, hydrate: true });
// Wait execute await task(); to be executed (if exists)
await Promise.resolve();
expect(screen.queryByTestId(SCRIPT_TEST_ID)).toBeTruthy();
expect(screen.queryByTestId(FRAGMENT_DIV_ID)).toBeTruthy();
// Force page rerender
fireEvent.click(screen.getByText("refresh:0"));
expect(screen.getByText("refresh:1")).toBeTruthy();
// Wait execute await task(); to be executed (if exists)
await Promise.resolve();
expect(screen.queryByTestId(SCRIPT_TEST_ID)).toBeTruthy();
expect(screen.queryByTestId(SCRIPT_PROCESSED_TEST_ID)).not.toBeTruthy();
expect(screen.queryByTestId(SCRIPT_TEST_ID)?.dataset.page).toEqual("0");
expect(screen.queryByTestId(FRAGMENT_DIV_ID)).toBeTruthy();
// Force page change
fireEvent.click(screen.getByText("page:0"));
expect(screen.getByText("page:1")).toBeTruthy();
// Wait execute await task(); to be executed (if exists)
await Promise.resolve();
expect(screen.queryByTestId(SCRIPT_TEST_ID)).not.toBeTruthy();
expect(screen.queryByTestId(SCRIPT_PROCESSED_TEST_ID)).toBeTruthy();
// Script has changed
expect(
screen.queryByTestId(SCRIPT_PROCESSED_TEST_ID)?.dataset.page
).toEqual("1");
expect(screen.queryByTestId(FRAGMENT_DIV_ID)).toBeTruthy();
});
/**
* Tests the behavior of script tags in an SSR context for a published site with `clientOnly` set to true:
* - SSR: Do not render html embed at all on server side.
* - Render HTMLEmbed atfer hydration, then executes scripts using additional processing described here https://ghinda.net/article/script-tags/.
* - Page Navigation: Executes scripts using additional processing described here https://ghinda.net/article/script-tags/.
*/
test("clientOnly === true", async () => {
const ui = <App clientOnly={true} />;
const container = document.createElement("div");
// Server rendering
document.body.appendChild(container);
container.innerHTML = ReactDOMServer.renderToString(ui);
expect(screen.queryByTestId(SCRIPT_TEST_ID)).not.toBeTruthy();
expect(screen.queryByTestId(SCRIPT_PROCESSED_TEST_ID)).not.toBeTruthy();
expect(screen.queryByTestId(FRAGMENT_DIV_ID)).not.toBeTruthy();
// Hydration
render(ui, { container, hydrate: true });
// Wait execute await task(); to be executed (if exists)
await Promise.resolve();
expect(screen.queryByTestId(SCRIPT_TEST_ID)).not.toBeTruthy();
expect(screen.queryByTestId(SCRIPT_PROCESSED_TEST_ID)).toBeTruthy();
expect(screen.queryByTestId(FRAGMENT_DIV_ID)).toBeTruthy();
// Force page rerender
fireEvent.click(screen.getByText("refresh:0"));
expect(screen.getByText("refresh:1")).toBeTruthy();
// Wait execute await task(); to be executed (if exists)
await Promise.resolve();
expect(screen.queryByTestId(SCRIPT_TEST_ID)).not.toBeTruthy();
expect(screen.queryByTestId(SCRIPT_PROCESSED_TEST_ID)).toBeTruthy();
expect(
screen.queryByTestId(SCRIPT_PROCESSED_TEST_ID)?.dataset.page
).toEqual("0");
expect(screen.queryByTestId(FRAGMENT_DIV_ID)).toBeTruthy();
// Force page change
fireEvent.click(screen.getByText("page:0"));
expect(screen.getByText("page:1")).toBeTruthy;
// Wait execute await task(); to be executed (if exists)
await Promise.resolve();
expect(screen.queryByTestId(SCRIPT_TEST_ID)).not.toBeTruthy();
expect(screen.queryByTestId(SCRIPT_PROCESSED_TEST_ID)).toBeTruthy();
expect(
screen.queryByTestId(SCRIPT_PROCESSED_TEST_ID)?.dataset.page
).toEqual("1");
expect(screen.queryByTestId(FRAGMENT_DIV_ID)).toBeTruthy();
});
});
/**
* On canvas renderer, scripts are not executed on the server side.
*/
describe("Builder renderer= canvas | preview", () => {
test("resolves root-relative asset paths for canvas markup", () => {
const { container } = render(
<App
renderer="canvas"
code={`
<script src="/test.js"></script>
<link rel="stylesheet" href="/site.css">
<img src="/hero.png">
`}
assetUrlsByPath={{
"/test.js": "/cgi/asset/test_hash.js",
"/site.css": "/cgi/asset/site_hash.css",
"/hero.png": "/cgi/asset/hero_hash.png",
}}
/>
);
expect(container.querySelector("script")?.getAttribute("src")).toBe(
"/cgi/asset/test_hash.js"
);
expect(container.querySelector("link")?.getAttribute("href")).toBe(
"/cgi/asset/site_hash.css"
);
expect(container.querySelector("img")?.getAttribute("src")).toBe(
"/cgi/asset/hero_hash.png"
);
});
test.each(["canvas", "preview"] as const)(
"waits for resources to settle before executing scripts in %s",
async (renderer) => {
const props = {
renderer,
executeScriptOnCanvas: true,
$ws$executeScripts: false,
};
const { rerender } = render(<App {...props} />);
expect(screen.queryByTestId(SCRIPT_TEST_ID)).toBeTruthy();
expect(screen.queryByTestId(SCRIPT_PROCESSED_TEST_ID)).not.toBeTruthy();
rerender(<App {...props} $ws$executeScripts={true} />);
await Promise.resolve();
expect(screen.queryByTestId(SCRIPT_TEST_ID)).not.toBeTruthy();
expect(screen.queryByTestId(SCRIPT_PROCESSED_TEST_ID)).toBeTruthy();
}
);
/**
* On canvas if renderer is canvas, and executeScriptOnCanvas=false
* scripts postprocessing are not applied independently of clientOnly value.
*/
test.each(
cartesian(
[true, false, undefined], //clientOnly
[false, undefined] //executeScriptOnCanvas
)
)(
"script processing are not applied when clientOnly=%p executeScriptOnCanvas=%p and renderer=canvas",
async (clientOnly, executeScriptOnCanvas) => {
const ui = (
<App
clientOnly={clientOnly}
renderer={"canvas"}
executeScriptOnCanvas={executeScriptOnCanvas}
/>
);
const container = document.createElement("div");
// Server rendering
document.body.appendChild(container);
// Hydration
render(ui, { container });
// Wait execute await task(); to be executed (if exists)
await Promise.resolve();
expect(screen.queryByTestId(SCRIPT_TEST_ID)).toBeTruthy();
expect(screen.queryByTestId(SCRIPT_PROCESSED_TEST_ID)).not.toBeTruthy();
expect(screen.queryByTestId(FRAGMENT_DIV_ID)).toBeTruthy();
fireEvent.click(screen.getByText("refresh:0"));
expect(screen.getByText("refresh:1")).toBeTruthy();
// Wait execute await task(); to be executed (if exists)
await Promise.resolve();
expect(screen.queryByTestId(SCRIPT_TEST_ID)).toBeTruthy();
expect(screen.queryByTestId(SCRIPT_PROCESSED_TEST_ID)).not.toBeTruthy();
expect(screen.queryByTestId(FRAGMENT_DIV_ID)).toBeTruthy();
}
);
/**
* Script postprocessing is always applied for preview mode, independently of other props
* Script postprocessing is always applied for canvas renderer if executeScriptOnCanvas = true or undefined
*/
test.each([
...cartesian(
[true, false, undefined], // clientOnly
["preview" as const], // renderer
[true, false, undefined] // executeScriptOnCanvas
),
...cartesian(
[true, false, undefined], // clientOnly
["canvas" as const], // renderer
[true] // executeScriptOnCanvas
),
])(
"In preview mode, script processing are applied when clientOnly=%p and renderer=%p and executeScriptOnCanvas=%p",
async (clientOnly, renderer, executeScriptOnCanvas) => {
const ui = (
<App
clientOnly={clientOnly}
renderer={renderer}
executeScriptOnCanvas={executeScriptOnCanvas}
/>
);
const container = document.createElement("div");
// Server rendering
document.body.appendChild(container);
// Hydration
render(ui, { container });
// Wait execute await task(); to be executed (if exists)
await Promise.resolve();
expect(screen.queryByTestId(SCRIPT_TEST_ID)).not.toBeTruthy();
expect(screen.queryByTestId(SCRIPT_PROCESSED_TEST_ID)).toBeTruthy();
expect(screen.queryByTestId(FRAGMENT_DIV_ID)).toBeTruthy();
fireEvent.click(screen.getByText("refresh:0"));
expect(screen.getByText("refresh:1")).toBeTruthy();
// Wait execute await task(); to be executed (if exists)
await Promise.resolve();
expect(screen.queryByTestId(SCRIPT_TEST_ID)).not.toBeTruthy();
expect(screen.queryByTestId(SCRIPT_PROCESSED_TEST_ID)).toBeTruthy();
expect(screen.queryByTestId(FRAGMENT_DIV_ID)).toBeTruthy();
document.body.removeChild(container);
}
);
test("Code change cause scripts to be updated", async () => {
const SCRIPT_TEST_ID_2 = "script-b";
const SCRIPT_PROCESSED_TEST_ID_2 = `${scriptTestIdPrefix}${SCRIPT_TEST_ID_2}`;
const codes = [
`<script data-testid="${SCRIPT_TEST_ID}">console.log('hello')</script>`,
`<script data-testid="${SCRIPT_TEST_ID_2}">console.log('hello')</script>`,
];
const AppWithCode = () => {
const [codeIndex, switchCodeIndex] = React.useReducer(
(n) => (n + 1) % 2,
0
);
const code = codes[codeIndex];
return (
<ReactSdkContext.Provider
value={{
assetBaseUrl: "",
imageLoader: () => "",
renderer: "canvas",
resources: {},
breakpoints: [],
onError: console.error,
}}
>
<HtmlEmbed code={code} executeScriptOnCanvas={true} />
<button type="button" onClick={switchCodeIndex}>
code:{codeIndex}
</button>
</ReactSdkContext.Provider>
);
};
const ui = <AppWithCode />;
const container = document.createElement("div");
// Server rendering
document.body.appendChild(container);
// Hydration
render(ui, { container });
// Wait execute await task(); to be executed (if exists)
await Promise.resolve();
expect(screen.queryByTestId(SCRIPT_TEST_ID)).not.toBeTruthy();
expect(screen.queryByTestId(SCRIPT_PROCESSED_TEST_ID)).toBeTruthy();
// Force page rerender
fireEvent.click(screen.getByText("code:0"));
// Wait execute await task(); to be executed (if exists)
await Promise.resolve();
expect(screen.queryByTestId(SCRIPT_TEST_ID)).not.toBeTruthy();
expect(screen.queryByTestId(SCRIPT_PROCESSED_TEST_ID)).not.toBeTruthy();
// Code has changed, new script processed
expect(screen.queryByTestId(SCRIPT_PROCESSED_TEST_ID_2)).toBeTruthy();
});
test.each(["", " "])("Placeholder is shown if code is %p", async (code) => {
const AppWithCode = () => {
return <HtmlEmbed code={code} executeScriptOnCanvas={true} />;
};
const ui = <AppWithCode />;
const container = document.createElement("div");
// Server rendering
document.body.appendChild(container);
// Hydration
render(ui, { container });
expect(
screen.queryByText(`Open the "Settings" panel to insert HTML code.`)
).toBeTruthy();
});
/**
* Disabled script execution takes precedence over canvas settings and renderer mode.
*/
test.each(
cartesian(
[true, false, undefined], //clientOnly
[true, false, undefined], //executeScriptOnCanvas
["canvas", "preview"] //renderer
)
)(
"Safe mode prevents script execution when clientOnly=%p executeScriptOnCanvas=%p and renderer=%p",
async (clientOnly, executeScriptOnCanvas, renderer) => {
const AppWithSafeMode = () => {
const code = `
<script data-testid="${SCRIPT_TEST_ID}">console.log('hello')</script>
<div data-testid="${FRAGMENT_DIV_ID}">hello</div>
`;
return (
<ReactSdkContext.Provider
value={{
assetBaseUrl: "",
imageLoader: () => "",
renderer: renderer as "canvas" | "preview",
resources: {},
breakpoints: [],
onError: console.error,
}}
>
<HtmlEmbed
code={code}
clientOnly={clientOnly}
executeScriptOnCanvas={executeScriptOnCanvas}
$ws$executeScripts={false}
/>
</ReactSdkContext.Provider>
);
};
const ui = <AppWithSafeMode />;
const container = document.createElement("div");
document.body.appendChild(container);
render(ui, { container });
await Promise.resolve();
// Scripts should not be processed (no SCRIPT_PROCESSED_TEST_ID)
expect(screen.queryByTestId(SCRIPT_TEST_ID)).toBeTruthy();
expect(screen.queryByTestId(SCRIPT_PROCESSED_TEST_ID)).not.toBeTruthy();
expect(screen.queryByTestId(FRAGMENT_DIV_ID)).toBeTruthy();
}
);
});