Compare commits

..
Author SHA1 Message Date
abc23d7721 perf(plugins): minify client bundles while preserving interop and notices (#8076)
Reduce plugin client payloads with whitespace and syntax minification after eager host-export interop. Preserve identifiers, lowered async behavior and dependency license notices; leave server bundles readable.

Verify worker-produced code through registration, byte-reduction, immutable-export and retained-notice regressions. Carries forward #7625 on an upstream branch.

Pin Playwright-installing CI jobs to Ubuntu 24.04 after the ubuntu-latest rollout selected an unsupported browser-install platform.

Follow-up payload review motivated by Matt Cowger's plugin-loading discussion: https://discord.com/channels/1481169421832814616/1563217536269680744

Co-authored-by: luke <luopeihuan@gmail.com>
Co-authored-by: RoundRedDot <roundreddot@gmail.com>
2026-10-28 09:50:29 +07:00
paseo-bot[bot] 2c23cd4927 test(cli): let test daemons allocate their listening ports (#8058)
Reserve-and-release port selection races other listeners during daemon startup. Bind port zero and use the existing daemon instance endpoint for readiness and callers.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-27 21:27:30 +00:00
12 changed files with 181 additions and 131 deletions
+11 -10
View File
@@ -133,11 +133,12 @@ jobs:
if: ${{ !cancelled() && steps.install.outcome == 'success' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.sdk != 'false') }}
run: npm run typecheck:examples --workspace=@getpaseo/client
# The locked Playwright installers do not support ubuntu-latest's Ubuntu 26.04.
typecheck:
name: typecheck
needs: changes
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.quality != 'false') }}
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
@@ -395,7 +396,7 @@ jobs:
name: app-tests
needs: changes
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.app != 'false') }}
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
@@ -429,7 +430,7 @@ jobs:
name: playwright (shard 1/8)
needs: changes
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
env:
PLAYWRIGHT_SHARD: "1/8"
PLAYWRIGHT_ARTIFACT: "1"
@@ -485,7 +486,7 @@ jobs:
name: playwright (shard 2/8)
needs: changes
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
env:
PLAYWRIGHT_SHARD: "2/8"
PLAYWRIGHT_ARTIFACT: "2"
@@ -495,7 +496,7 @@ jobs:
name: playwright (shard 3/8)
needs: changes
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
env:
PLAYWRIGHT_SHARD: "3/8"
PLAYWRIGHT_ARTIFACT: "3"
@@ -505,7 +506,7 @@ jobs:
name: playwright (shard 4/8)
needs: changes
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
env:
PLAYWRIGHT_SHARD: "4/8"
PLAYWRIGHT_ARTIFACT: "4"
@@ -515,7 +516,7 @@ jobs:
name: playwright (shard 5/8)
needs: changes
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
env:
PLAYWRIGHT_SHARD: "5/8"
PLAYWRIGHT_ARTIFACT: "5"
@@ -525,7 +526,7 @@ jobs:
name: playwright (shard 6/8)
needs: changes
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
env:
PLAYWRIGHT_SHARD: "6/8"
PLAYWRIGHT_ARTIFACT: "6"
@@ -535,7 +536,7 @@ jobs:
name: playwright (shard 7/8)
needs: changes
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
env:
PLAYWRIGHT_SHARD: "7/8"
PLAYWRIGHT_ARTIFACT: "7"
@@ -545,7 +546,7 @@ jobs:
name: playwright (shard 8/8)
needs: changes
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
env:
PLAYWRIGHT_SHARD: "8/8"
PLAYWRIGHT_ARTIFACT: "8"
+4 -6
View File
@@ -150,20 +150,18 @@ One row off the rail makes the whole card look unconsidered.
Compact-first. The small case is designed; the large case adds chrome around it.
Use `md: 600` for both the compact/wide layout boundary and responsive density, in logical points on native and CSS pixels on web. Do not add a separate density breakpoint. Measured content constraints remain independent: composer controls use `COMPACT_CONTENT_WIDTH` (500), so a narrow pane can keep compact controls inside a wide window.
The settings shell (`packages/app/src/screens/settings-screen.tsx`) and the projects screen (`packages/app/src/screens/projects-screen.tsx`) share the canonical list+detail navigation pattern:
The list+detail pattern is canonical and reused across surfaces. The settings shell (`packages/app/src/screens/settings-screen.tsx`) and the projects screen (`packages/app/src/screens/projects-screen.tsx`) implement it identically:
- On compact: full-screen list with `<BackHeader>` at the top. Tapping a row pushes a full-screen detail with its own `<BackHeader>` that returns to the list.
- On wide layouts: a sidebar on the left holds the list with `surfaceSidebar` background. The content pane on the right holds the selected detail with `<ScreenHeader>`, `<HeaderIconBadge>`, and `<ScreenTitle>`.
- On desktop: a 320px sidebar on the left holds the list with `surfaceSidebar` background. The content pane on the right holds the selected detail with `<ScreenHeader>`, `<HeaderIconBadge>`, and `<ScreenTitle>`.
The branching is one `useIsCompactFormFactor()` check at the top of the screen component. The list and the detail are the same components in both layouts; only the framing changes.
The workspace screen (`packages/app/src/screens/workspace/workspace-screen.tsx`) follows a different but parallel rule: tabs collapse on compact, panes split on desktop. The sidebar (`packages/app/src/components/left-sidebar.tsx`) is overlaid on compact and pinned on desktop.
On a narrow window using the wide layout, app navigation yields when the remaining width cannot accommodate the content topology. Settings prefers a 720px split (320px list + 400px detail); its list shrinks first to keep forms readable without reducing the detail below 400px. Explorer reserves at least 240px plus a 360px workspace. These are local space budgets, not compact breakpoints. The app sidebar shrinks from its preferred 320px toward a 200px minimum to leave a 400px center target; its preferred width fits at 720px. Temporary width clamps are render-only; widening restores the user's saved sidebar widths.
On a narrow desktop route, app navigation yields to the rendered content topology when the remaining width cannot preserve its center target: Settings keeps its 320px list + 400px detail split, and a workspace Explorer keeps its current visible width plus a 400px center pane. That is a topology decision at the app container, not a second compact breakpoint. Temporary width clamps are render-only; widening restores the user's saved sidebar widths.
Electron window controls are top-corner obstructions, not a compact-layout condition. Rendered surfaces declare which top corners they physically occupy; only those corners receive clearance. Full-window overlays redeclare both corners. A focused split pane owns both corners; if focus restoration temporarily exposes the full split tree, the split boundary reserves one top strip instead of assigning a control rectangle to an arbitrarily narrow leaf.
Electron window controls are top-corner obstructions, not a compact-layout condition. Rendered surfaces declare which top corners they physically occupy; only those corners receive clearance. Full-window overlays redeclare both corners. A focused split pane owns both corners; if focus restoration temporarily exposes the full split tree, the split boundary reserves one top strip instead of assigning a control rectangle to an arbitrarily narrow leaf. The 720px desktop breakpoint preserves the default 320px sidebar and target 400px center width when the Explorer is closed; it is product policy, not an obstruction gate.
Windows and Linux controls are fixed window chrome, outside scrolling header content. A tab rail that reaches them ends at their obstruction and shows the shared overflow fade. On macOS, the Explorer toggle occupies a fixed top-right window slot so opening and closing Explorer does not move the pointer target.
@@ -479,76 +479,3 @@ test.describe("Settings — compact master-detail", () => {
await expect(page.getByTestId("welcome-direct-connection")).toBeVisible();
});
});
async function measureDockedNavigation(page: import("@playwright/test").Page, width: number) {
await page.setViewportSize({ width, height: 951 });
const history = page.getByRole("button", { name: /^History/ }).filter({ visible: true });
const settings = page
.getByRole("button", { name: "Settings", exact: true })
.filter({ visible: true });
await expect(history).toBeVisible();
await expect(settings).toBeVisible();
return {
width,
history: await history.evaluate((el) => el.getBoundingClientRect().height),
settings: await settings.evaluate((el) => el.getBoundingClientRect().height),
};
}
async function measureSettingsSplit(page: import("@playwright/test").Page, width: number) {
await page.setViewportSize({ width, height: 951 });
const sidebar = page.getByTestId("settings-sidebar");
const detail = page.getByTestId("settings-detail-pane");
await expect(sidebar).toBeVisible();
await expect(detail).toBeVisible();
return {
width,
sidebar: await sidebar.evaluate((el) => el.getBoundingClientRect().width),
detail: await detail.evaluate((el) => el.getBoundingClientRect().width),
};
}
async function expectConsistentDockedNavigation(page: import("@playwright/test").Page) {
const wide = await measureDockedNavigation(page, 951);
for (const width of [600, 669, 719, 720]) {
const narrow = await measureDockedNavigation(page, width);
expect
.soft(narrow.history, `History keeps its wide-layout density at ${width}`)
.toBeCloseTo(wide.history, 0);
expect
.soft(narrow.settings, `Settings keeps its wide-layout density at ${width}`)
.toBeCloseTo(wide.settings, 0);
}
}
async function expectSettingsDetailKeepsReadingWidth(page: import("@playwright/test").Page) {
const reference = await measureSettingsSplit(page, 720);
for (const width of [600, 669, 719]) {
const narrow = await measureSettingsSplit(page, width);
expect
.soft(narrow.detail, `Settings preserves its detail budget at ${width}`)
.toBeGreaterThanOrEqual(reference.detail);
expect
.soft(narrow.sidebar, `Settings navigation yields width at ${width}`)
.toBeLessThan(reference.sidebar);
}
const wide = await measureSettingsSplit(page, 951);
expect(wide.sidebar).toBeCloseTo(reference.sidebar, 0);
expect(wide.detail).toBeGreaterThan(reference.detail);
}
test("docked navigation keeps its density and Settings preserves reading width", async ({
page,
withWorkspace,
}) => {
const workspace = await withWorkspace({ prefix: "narrow-wide-layout-" });
await gotoWorkspace(page, workspace.workspaceId);
await expectConsistentDockedNavigation(page);
await openSettings(page);
await openSettingsSection(page, "appearance");
await expectAppearanceContent(page);
await expectSettingsDetailKeepsReadingWidth(page);
await page.setViewportSize({ width: 599, height: 951 });
await expectSettingsSidebarHidden(page);
await expectAppearanceContent(page);
});
@@ -41,7 +41,7 @@ import {
useWorkspaceAttachmentsStore,
} from "@/attachments/workspace-attachments-store";
import type { UserMessageImageAttachment } from "@/types/stream";
import { COMPACT_CONTENT_WIDTH, useIsCompactFormFactor } from "@/constants/layout";
import { COMPACT_FORM_FACTOR_WIDTH, useIsCompactFormFactor } from "@/constants/layout";
import { isWeb } from "@/constants/platform";
import {
buildWorkspaceTabPersistenceKey,
@@ -390,7 +390,7 @@ export function WorkspaceDraftAgentTab({
const editingSession = useComposerEditingSession("chat");
const isComposerFullscreen = useComposerFullscreen(editingSession);
const { onLayout: onInputAreaLayout, isBelow: isCompactComposerLayout } = useContainerWidthBelow(
COMPACT_CONTENT_WIDTH,
COMPACT_FORM_FACTOR_WIDTH,
{ initialIsBelow: isCompactFormFactor },
);
const workspaceAttachmentScopeKey = useWorkspaceAttachmentScopeKey({
+4 -5
View File
@@ -11,11 +11,10 @@ export const HEADER_INNER_HEIGHT_MOBILE = 56;
export const WORKSPACE_SECONDARY_HEADER_HEIGHT = 36;
export const HEADER_TOP_PADDING_MOBILE = 8;
// Measured composer width; independent of the window's compact/wide breakpoint.
export const COMPACT_CONTENT_WIDTH = 500;
export const COMPACT_FORM_FACTOR_WIDTH = 500;
// Settings navigation can shrink below its preferred width to preserve the detail
// minimum. Both targets must fit before Settings shares width with app navigation.
// Settings uses the canonical desktop list + detail layout. Its sidebar and
// detail target must fit together before it can share width with app navigation.
export const SETTINGS_DESKTOP_SIDEBAR_WIDTH = 320;
export const SETTINGS_DESKTOP_DETAIL_MIN_WIDTH = 400;
export const SETTINGS_DESKTOP_SPLIT_MIN_WIDTH =
@@ -34,7 +33,7 @@ export {
getIsElectronMac as getIsElectronRuntimeMac,
} from "./platform";
/** Below md (600 points), sidebars overlay the workspace; wider layouts can dock them. */
/** Below 600 points, sidebars overlay the workspace. Medium and wider layouts dock them. */
export function isCompactBreakpoint(breakpoint: string | undefined): boolean {
return breakpoint === "xs" || breakpoint === "sm";
}
+2 -2
View File
@@ -44,7 +44,7 @@ import { useAgentDescriptor } from "@/agent-presentation";
import { useToastHost, type ToastApi, type ToastState } from "@/components/toast-host";
import type { WorkspaceComposerAttachment } from "@/attachments/types";
import { useWorkspaceAttachmentScopeKey } from "@/attachments/workspace-attachments-store";
import { COMPACT_CONTENT_WIDTH, useIsCompactFormFactor } from "@/constants/layout";
import { COMPACT_FORM_FACTOR_WIDTH, useIsCompactFormFactor } from "@/constants/layout";
import { isNative, isWeb } from "@/constants/platform";
import { useAgentAttentionClear } from "@/hooks/use-agent-attention-clear";
import { useAgentInputDraft, type AgentInputDraft } from "@/composer/draft/input-draft";
@@ -1512,7 +1512,7 @@ function ActiveAgentComposer({
}) {
const isCompactFormFactor = useIsCompactFormFactor();
const { onLayout: onInputAreaLayout, isBelow: isCompactComposerLayout } = useContainerWidthBelow(
COMPACT_CONTENT_WIDTH,
COMPACT_FORM_FACTOR_WIDTH,
{ initialIsBelow: isCompactFormFactor },
);
const paneContext = usePaneContext();
+1 -7
View File
@@ -122,11 +122,7 @@ import { MetadataGenerationPage } from "@/screens/settings/metadata-generation-p
import { HostVoicePage } from "@/screens/settings/voice/voice-page";
import ProjectsScreen from "@/screens/projects-screen";
import ProjectSettingsScreen from "@/screens/project-settings-screen";
import {
SETTINGS_DESKTOP_DETAIL_MIN_WIDTH,
SETTINGS_DESKTOP_SIDEBAR_WIDTH,
useIsCompactFormFactor,
} from "@/constants/layout";
import { SETTINGS_DESKTOP_SIDEBAR_WIDTH, useIsCompactFormFactor } from "@/constants/layout";
import { densityIconBox, densityIconSize } from "@/styles/density";
import { useLocalDaemonServerId } from "@/hooks/use-is-local-daemon";
import {
@@ -1548,14 +1544,12 @@ const desktopStyles = StyleSheet.create({
},
contentPane: {
flex: 1,
minWidth: SETTINGS_DESKTOP_DETAIL_MIN_WIDTH,
},
});
const sidebarStyles = StyleSheet.create((theme) => ({
desktopContainer: {
width: SETTINGS_DESKTOP_SIDEBAR_WIDTH,
flexShrink: 1,
borderRightWidth: 1,
borderRightColor: theme.colors.border,
backgroundColor: theme.colors.surfaceSidebar,
+3 -1
View File
@@ -6,7 +6,8 @@ StyleSheet.configure({
breakpoints: {
xs: 0,
sm: 576,
md: 600,
medium: 600,
md: 720,
lg: 992,
xl: 1200,
},
@@ -21,6 +22,7 @@ type AppThemes = typeof REGISTERED_THEMES;
interface AppBreakpoints {
xs: number;
sm: number;
medium: number;
md: number;
lg: number;
xl: number;
@@ -0,0 +1,16 @@
#!/usr/bin/env npx tsx
import assert from "node:assert/strict";
import { startTestDaemon, runPaseoCli } from "./helpers/test-daemon.ts";
// Port zero leaves allocation to the listening daemon, with no reserve/release gap.
const daemon = await startTestDaemon({ port: 0 });
try {
assert(daemon.port > 0, "the helper returns the port the daemon actually bound");
assert.equal(daemon.wsUrl, `ws://127.0.0.1:${daemon.port}`);
const result = await runPaseoCli(daemon, ["agent", "ls", "--host", daemon.wsUrl, "--json"]);
assert.equal(result.exitCode, 0, result.stderr);
assert.deepEqual(JSON.parse(result.stdout), []);
} finally {
await daemon.stop();
}
+22 -24
View File
@@ -18,7 +18,7 @@ import { tmpdir } from "os";
import { join } from "path";
import { fileURLToPath } from "url";
import { ChildProcess, spawn } from "child_process";
import { getAvailablePort } from "./network.ts";
import { readDaemonInstance } from "@getpaseo/server/daemon-control";
export interface TestDaemonContext {
/** Available local port for test daemon (never 6767) */
@@ -195,17 +195,20 @@ async function probeDaemonReady(
}
async function waitForDaemonReady(
port: number,
paseoHome: string,
timeout = 30000,
env?: NodeJS.ProcessEnv,
): Promise<void> {
): Promise<number> {
const deadline = Date.now() + timeout;
async function poll(): Promise<void> {
if (await probeDaemonReady(port, paseoHome, env)) return;
async function poll(): Promise<number> {
const instance = await readDaemonInstance(paseoHome);
if (instance?.listen) {
const port = Number(new URL(`http://${instance.listen}`).port);
if (await probeDaemonReady(port, paseoHome, env)) return port;
}
if (Date.now() >= deadline) {
throw new Error(`Daemon failed to become ready on port ${port} within ${timeout}ms`);
throw new Error(`Daemon failed to publish a ready endpoint within ${timeout}ms`);
}
await sleep(100);
return poll();
@@ -231,15 +234,14 @@ export async function startTestDaemon(options?: {
timeout?: number;
env?: NodeJS.ProcessEnv;
}): Promise<TestDaemonContext> {
const port = options?.port ?? (await getAvailablePort());
// Let the listening process allocate its port, without a reserve/release race.
const requestedPort = options?.port ?? 0;
const { paseoHome, workDir } =
options?.paseoHome && options?.workDir
? { paseoHome: options.paseoHome, workDir: options.workDir }
: await createTempDirs();
const timeout = options?.timeout ?? 30000;
const wsUrl = `ws://${TEST_DAEMON_HOST}:${port}`;
// Find the CLI entry point - use the source file directly with tsx
const cliDir = join(import.meta.dirname, "..", "..");
const cliSrcPath = join(cliDir, "src", "index.ts");
@@ -252,7 +254,7 @@ export async function startTestDaemon(options?: {
),
...TEST_DAEMON_ENV_DEFAULTS,
PASEO_HOME: paseoHome,
PASEO_LISTEN: `${TEST_DAEMON_HOST}:${port}`,
PASEO_LISTEN: `${TEST_DAEMON_HOST}:${requestedPort}`,
// Force no TTY to prevent QR code output
CI: "true",
...options?.env,
@@ -312,20 +314,18 @@ export async function startTestDaemon(options?: {
}
});
const ctx: TestDaemonContext = {
port,
wsUrl,
paseoHome,
workDir,
process: daemonProcess,
isReady: false,
stop: cleanup,
};
// Wait for daemon to be ready
try {
await waitForDaemonReady(port, paseoHome, timeout, options?.env);
ctx.isReady = true;
const port = await waitForDaemonReady(paseoHome, timeout, options?.env);
return {
port,
wsUrl: `ws://${TEST_DAEMON_HOST}:${port}`,
paseoHome,
workDir,
process: daemonProcess,
isReady: true,
stop: cleanup,
};
} catch (err) {
// Daemon failed to start - clean up and rethrow
await cleanup();
@@ -335,8 +335,6 @@ export async function startTestDaemon(options?: {
{ cause: err },
);
}
return ctx;
}
/**
@@ -257,6 +257,22 @@ function makeHermesInteropEager(code: string): string {
return code.replaceAll("get: () => from[key]", "value: from[key]");
}
async function minifyClientBundle(code: string): Promise<string> {
const { transform } = loadEsbuild();
// Minify after the eager replace. Whitespace minify rewrites the getter this
// replace matches, and identifier minify can delete the helper entirely.
const result = await transform(code, {
minifyWhitespace: true,
minifySyntax: true,
minifyIdentifiers: false,
target: "es2020",
supported: { "async-await": false },
charset: "ascii",
legalComments: "inline",
});
return result.code;
}
function runtimeSpecifierError(
specifier: string,
target: PluginBuildTarget | "shared",
@@ -368,7 +384,10 @@ async function compileTarget(entryPath: string, target: PluginBuildTarget): Prom
checkSharedDependencies(result.metafile.inputs, pluginDirectory);
const output = result.outputFiles[0]?.text;
if (!output) throw new Error(`Plugin ${target} compilation produced no output`);
return wrapCommonJsBundle(makeHermesInteropEager(output));
const eager = makeHermesInteropEager(output);
let prepared = eager;
if (target === "client") prepared = await minifyClientBundle(eager);
return wrapCommonJsBundle(prepared);
}
export async function compilePlugin(entryPaths: {
@@ -3,6 +3,7 @@ import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import * as zod from "zod";
import { ZodError } from "zod";
import {
compilePlugin,
@@ -569,6 +570,101 @@ export default function contribute(client) {
expect(clientBundle).toContain("__async");
});
it("reduces client bytes while preserving registration and leaving server output readable", async () => {
const entries = await createSplitPlugin();
const source = `import { z } from "zod";
const commandSchema = z.object({ id: z.string(), title: z.string() });
export default function contribute(client) {
const command = commandSchema.parse({ id: "open", title: "Open – plugin" });
client.addCommandCenterItem({
...command,
icon: "Scan",
context: "global",
onSelect() { return Promise.resolve(command.title); },
});
return () => undefined;
}`;
await Promise.all([writeFile(entries.client, source), writeFile(entries.server, source)]);
const { clientBundle, serverBundle } = await compilePlugin(entries);
if (clientBundle === null || serverBundle === null) {
throw new Error("compilePlugin returned no bundle");
}
for (const bundle of [clientBundle, serverBundle]) {
const registered: { id: string; title: string; onSelect: () => Promise<string> }[] = [];
const exports = new Function(`return ${bundle}`)()((specifier: string) => {
expect(specifier).toBe("zod");
return zod;
});
const cleanup = exports.default({
addCommandCenterItem: (command: (typeof registered)[number]) => registered.push(command),
});
expect(registered).toMatchObject([{ id: "open", title: "Open – plugin" }]);
await expect(registered[0]!.onSelect()).resolves.toBe("Open – plugin");
expect(cleanup()).toBeUndefined();
}
expect(Buffer.byteLength(clientBundle)).toBeLessThan(Buffer.byteLength(serverBundle) * 0.9);
expect(clientBundle.split("").every((char) => char.charCodeAt(0) <= 0x7f)).toBe(true);
expect(serverBundle).toContain('title: "Open');
});
it("retains dependency license notices while minifying executable client code", async () => {
const entries = await createSplitPlugin();
const dependency = path.join(entries.directory, "node_modules/licensed-dependency");
const notice = "Copyright Example Authors. SPDX-License-Identifier: MIT";
await mkdir(dependency, { recursive: true });
await writeFile(
path.join(dependency, "package.json"),
JSON.stringify({ name: "licensed-dependency", main: "index.js" }),
);
await writeFile(
path.join(dependency, "index.js"),
`/*! ${notice} */
exports.title = function title(input) {
const trimmed = input.trim();
return "Licensed " + trimmed;
};`,
);
const source = `import { title } from "licensed-dependency";
export default function contribute() { return title(" plugin "); }`;
await Promise.all([writeFile(entries.client, source), writeFile(entries.server, source)]);
const { clientBundle, serverBundle } = await compilePlugin(entries);
if (clientBundle === null || serverBundle === null) {
throw new Error("compilePlugin returned no bundle");
}
for (const bundle of [clientBundle, serverBundle]) {
expect(bundle).toContain(notice);
const exports = new Function(`return ${bundle}`)()(() => {
throw new Error("The dependency must be bundled");
});
expect(exports.default()).toBe("Licensed plugin");
}
expect(Buffer.byteLength(clientBundle)).toBeLessThan(Buffer.byteLength(serverBundle) * 0.9);
});
it("keeps distinct host exports eager when minifying in the worker", async () => {
const entries = await createSplitPlugin();
await writeFile(
entries.client,
`import * as host from "zod";
export default function contribute() { return [host.first, host.second]; }`,
);
const { clientBundle } = await compilePlugin({ client: entries.client, server: null });
const host = { first: "first export", second: "second export" };
const factory = new Function(`return ${clientBundle}`)() as (
require: (specifier: string) => typeof host,
) => { default: () => unknown };
function requireHost(specifier: string): typeof host {
expect(specifier).toBe("zod");
return host;
}
const exports = factory(requireHost);
host.first = "changed first export";
host.second = "changed second export";
expect(exports.default()).toEqual(["first export", "second export"]);
});
it("rejects node imports from the client entry", async () => {
const entries = await createSplitPlugin();
await writeFile(