Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/polite-assets-kneel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"sideshow": patch
---

Respect configured base paths for uploaded asset URLs and native image/trace surface loads.
21 changes: 20 additions & 1 deletion e2e/fixtures.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { expect, test as base, type Locator, type Page } from "@playwright/test";
import { type ChildProcess, spawn } from "node:child_process";
import { mkdtempSync } from "node:fs";
import { mkdtempSync, readFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { fileURLToPath } from "node:url";
import { join } from "node:path";

const embedDir = fileURLToPath(new URL("../viewer/dist-embed", import.meta.url));

type ServerHandle = { url: string; stop: () => void };

type PublicReadServer = { url: string; token: string; mode: "full" | "session" };
Expand Down Expand Up @@ -141,6 +143,23 @@ export async function expectNoHorizontalOverflow(page: Page, selector: string) {
.toBeLessThanOrEqual(1);
}

function embedContentType(path: string): string {
if (path.endsWith(".js") || path.endsWith(".mjs")) return "text/javascript";
if (path.endsWith(".wasm")) return "application/wasm";
if (path.endsWith(".css")) return "text/css";
return "application/octet-stream";
}

export async function serveEmbedBundle(page: Page) {
await page.route("**/__embed/**", (route) => {
const name = new URL(route.request().url()).pathname.replace("/__embed/", "");
route.fulfill({
contentType: embedContentType(name),
body: readFileSync(`${embedDir}/${name}`),
});
});
}

export async function expectIframesNoHorizontalOverflow(page: Page, container: Locator) {
const frameUrls = await container
.locator("iframe")
Expand Down
65 changes: 65 additions & 0 deletions e2e/uploads.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,29 @@ import {
expectNoHorizontalOverflow,
publish,
publishParts,
serveEmbedBundle,
test,
TINY_PNG_B64,
upload,
} from "./fixtures.ts";

const embedHtml = (sessionId: string) => `<!doctype html>
<html><head><meta charset="utf-8"><style>html,body{margin:0;height:100%}#m{position:fixed;inset:0}</style></head>
<body><div id="m"></div>
<script type="module">
import { mountViewer } from "/__embed/engine.js";
mountViewer(document.getElementById("m"), {
basePath: "/u/alice",
layout: "stream",
readonly: true,
router: {
get: () => ({ sessionId: ${JSON.stringify(sessionId)} }),
navigate() {},
subscribe() { return () => {}; },
},
});
</script></body></html>`;

test("an image surface renders an <img> served from /a/:id", async ({ page, server }) => {
const asset = await upload(server.url, {
data: TINY_PNG_B64,
Expand All @@ -34,6 +52,53 @@ test("an image surface renders an <img> served from /a/:id", async ({ page, serv
await expect(page.locator(".asset-caption")).toHaveText("one pixel");
});

test("embedded native image and trace assets use the host base path", async ({ page, server }) => {
const image = await upload(server.url, {
data: TINY_PNG_B64,
contentType: "image/png",
filename: "pixel.png",
kind: "image",
});
const jsonl = '{"label":"from prefixed asset","kind":"shell"}';
const trace = await upload(server.url, {
data: Buffer.from(jsonl).toString("base64"),
contentType: "application/x-ndjson",
filename: "trace.jsonl",
kind: "trace",
session: image.sessionId,
});
await publishParts(server.url, {
title: "Prefixed assets",
agent: "e2e",
session: image.sessionId,
parts: [
{ kind: "image", assetId: image.id, caption: "prefixed image" },
{ kind: "trace", assetId: trace.id },
],
});

await page.route("**/__embedtest", (route) =>
route.fulfill({ contentType: "text/html", body: embedHtml(image.sessionId) }),
);
await serveEmbedBundle(page);
await page.route("**/u/alice/**", (route) => {
const url = new URL(route.request().url());
url.pathname = url.pathname.replace(/^\/u\/alice(?=\/|$)/, "") || "/";
route.continue({ url: url.toString() });
});

await page.goto(`${server.url}/__embedtest`);

const card = page.locator(".card:not(#whatsNew)");
const img = card.locator(".asset-img");
await expect(img).toHaveAttribute("src", `/u/alice/a/${image.id}`);
await expect
.poll(() => img.evaluate((el: HTMLImageElement) => el.naturalWidth))
.toBeGreaterThan(0);
await expect(card.locator(".trace-dl")).toHaveAttribute("href", `/u/alice/a/${trace.id}`);
await expect(card.locator(".trace-label")).toHaveText("from prefixed asset");
});

test("a trace surface renders a step timeline with expandable detail", async ({ page, server }) => {
await publishParts(server.url, {
title: "Run trace",
Expand Down
4 changes: 3 additions & 1 deletion server/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1554,7 +1554,9 @@ export function createApp({
const result = await uploadAsset(body);
if ("error" in result) return c.json({ error: result.error }, result.status);
const origin = new URL(c.req.url).origin;
return c.json({ ...result.asset, url: `${origin}/a/${result.asset.id}` }, 201);
const publicBasePath = requestBasePath(c.req.raw);
const assetPath = encodeURIComponent(result.asset.id);
return c.json({ ...result.asset, url: `${origin}${publicBasePath}/a/${assetPath}` }, 201);
});

app.get("/a/:id", async (c) => {
Expand Down
11 changes: 11 additions & 0 deletions test/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1740,6 +1740,17 @@ test("uploads an asset via base64 JSON and serves the exact bytes", async () =>
assert.deepEqual([...new Uint8Array(await served.arrayBuffer())], [137, 80, 78, 71, 0, 255]);
});

test("asset upload URL respects configured base path", async () => {
const app = makeApp(undefined, { basePath: "/u/alice" });
const res = await app.request(
"https://board.test/api/assets",
json({ data: b64([1, 2, 3]), contentType: "image/png" }),
);
assert.equal(res.status, 201);
const asset = (await res.json()) as any;
assert.equal(asset.url, `https://board.test/u/alice/a/${asset.id}`);
});

test("uploads raw bytes with metadata from the query string", async () => {
const app = makeApp();
const res = await app.request("/api/assets?filename=trace.json&kind=trace", {
Expand Down
9 changes: 5 additions & 4 deletions viewer/src/ImageSurface.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { createSignal, Show } from "solid-js";
import type { ImageSurface as ImageSurfaceData } from "./api.ts";
import { appPath, type ImageSurface as ImageSurfaceData } from "./api.ts";

// A trusted, viewer-chrome <img> for an uploaded asset (no iframe). The bytes
// live at /a/:id; an evicted/missing asset 404s, so show a placeholder rather
// than a broken image. Clicking opens the asset in a new tab.
// live at /a/:id under the host base path; an evicted/missing asset 404s, so
// show a placeholder rather than a broken image. Clicking opens the asset in a
// new tab.
export function ImageSurface(props: { surface: ImageSurfaceData }) {
const [failed, setFailed] = createSignal(false);
const src = () => `/a/${props.surface.assetId}`;
const src = () => appPath(`/a/${encodeURIComponent(props.surface.assetId)}`);
return (
<div class="image-surface">
<Show
Expand Down
21 changes: 14 additions & 7 deletions viewer/src/TraceSurface.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { createSignal, For, onMount, Show } from "solid-js";
import type { TraceSurface as TraceSurfaceData, TraceStep } from "./api.ts";
import { appPath, type TraceSurface as TraceSurfaceData, type TraceStep } from "./api.ts";

// Render an agent trace as a step timeline the user can scan beside the post.
// Steps may travel inline in the surface, or live in an uploaded JSON/JSONL
Expand All @@ -9,9 +9,14 @@ export function TraceSurface(props: { surface: TraceSurfaceData }) {
const [steps, setSteps] = createSignal<TraceStep[]>(props.surface.steps ?? []);
const [note, setNote] = createSignal<string | null>(null);

const assetUrl = () =>
props.surface.assetId ? appPath(`/a/${encodeURIComponent(props.surface.assetId)}`) : null;

onMount(() => {
if ((props.surface.steps?.length ?? 0) > 0 || !props.surface.assetId) return;
void fetch(`/a/${props.surface.assetId}`)
if ((props.surface.steps?.length ?? 0) > 0) return;
const url = assetUrl();
if (!url) return;
void fetch(url)
.then((r) => (r.ok ? r.text() : Promise.reject(new Error(String(r.status)))))
.then((text) => setSteps(parseTrace(text)))
.catch(() => setNote("Trace file unavailable — it may have been evicted."));
Expand All @@ -21,10 +26,12 @@ export function TraceSurface(props: { surface: TraceSurfaceData }) {
<div class="trace-surface">
<div class="trace-head">
<span class="trace-title">{props.surface.title ?? "Agent trace"}</span>
<Show when={props.surface.assetId}>
<a class="trace-dl" href={`/a/${props.surface.assetId}`} target="_blank" rel="noopener">
download ↓
</a>
<Show when={assetUrl()} keyed>
{(url) => (
<a class="trace-dl" href={url} target="_blank" rel="noopener">
download ↓
</a>
)}
</Show>
</div>
<Show when={note()}>
Expand Down
Loading