mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-28 04:03:05 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
abc23d7721 | ||
|
|
2c23cd4927 |
+11
-10
@@ -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
@@ -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({
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user