mirror of
https://github.com/webstudio-is/webstudio.git
synced 2026-11-02 18:18:35 +09:00
## 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
472 lines
16 KiB
TypeScript
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();
|
|
}
|
|
);
|
|
});
|