= ({
const workspaceName = currentWorkspace?.project_name || currentWorkspace?.path?.split('/').pop() || '';
const gitBranch = currentWorkspace?.git_branch;
const displayName = liveTitle || sessionName || t('chat.session');
+ const isRemoteWorkspace = currentWorkspace?.workspace_kind === 'remote'
+ || Boolean(currentWorkspace?.remote_connection_id)
+ || Boolean(currentWorkspace?.remote_ssh_host);
return (
@@ -1232,8 +1239,8 @@ const ChatPage: React.FC = ({
setMenuMessage(null)}
onCopy={() => void handleCopyMessage()}
onDelete={() => void handleDeleteMessage()}
diff --git a/src/mobile-web/src/services/SessionRecordPresentation.ts b/src/mobile-web/src/services/SessionRecordPresentation.ts
index be88b2ec94..d6b7699616 100644
--- a/src/mobile-web/src/services/SessionRecordPresentation.ts
+++ b/src/mobile-web/src/services/SessionRecordPresentation.ts
@@ -9,6 +9,28 @@ export interface MobileStoredTurn {
}
interface RichText { id: string; content: string; orderIndex?: number; timestamp?: number; status?: string; subagentSessionId?: string }
interface RichTool { id: string; toolName: string; orderIndex?: number; status?: string; startTime?: number; durationMs?: number; subagentSessionId?: string; toolCall: {id:string;input:unknown}; toolResult?:{result:unknown;success:boolean;error?:string} }
+
+/**
+ * Streamed turn records carry user attachment pixels in
+ * `userMessage.metadata.images`; the timeline and rollback/edit drafts read
+ * them from `ChatMessage.images`. Keep entries whose pixels arrived so a
+ * rollback never re-sends a blank frame.
+ */
+function presentUserMessageImages(metadata: unknown): ChatMessage['images'] {
+ if (!metadata || typeof metadata !== 'object') return undefined;
+ const images = (metadata as { images?: unknown }).images;
+ if (!Array.isArray(images)) return undefined;
+ const presented = images.flatMap((image) => {
+ if (!image || typeof image !== 'object') return [];
+ const record = image as { name?: unknown; data_url?: unknown };
+ const dataUrl = typeof record.data_url === 'string' ? record.data_url.trim() : '';
+ if (!dataUrl) return [];
+ const name = typeof record.name === 'string' && record.name.trim() ? record.name : 'image';
+ return [{ name, data_url: dataUrl }];
+ });
+ return presented.length > 0 ? presented : undefined;
+}
+
export function presentSessionTurn(turn: MobileStoredTurn): ChatMessage[] {
const items: ChatMessageItem[] = [...turn.modelRounds].sort((a,b)=>a.roundIndex-b.roundIndex).flatMap(round => {
const ordered = [
@@ -25,7 +47,7 @@ export function presentSessionTurn(turn: MobileStoredTurn): ChatMessage[] {
});
});
return [
- {id:turn.userMessage.id,turn_id:turn.turnId,turn_index:turn.turnIndex,role:'user',content:turn.userMessage.content,timestamp:String(turn.userMessage.timestamp),metadata:turn.userMessage.metadata},
+ {id:turn.userMessage.id,turn_id:turn.turnId,turn_index:turn.turnIndex,role:'user',content:turn.userMessage.content,timestamp:String(turn.userMessage.timestamp),metadata:turn.userMessage.metadata,images:presentUserMessageImages(turn.userMessage.metadata)},
{id:`${turn.turnId}_assistant`,turn_id:turn.turnId,role:'assistant',content:items.filter(i=>i.type==='text').map(i=>i.content??'').join(''),thinking:items.filter(i=>i.type==='thinking').map(i=>i.content??'').join(''),items,tools:items.flatMap(i=>i.tool?[i.tool]:[]),status:turn.status==='inprogress'?'streaming':turn.status,error:turn.error,timestamp:String(turn.timestamp)},
];
}
diff --git a/src/mobile-web/src/services/chatImageAttachments.ts b/src/mobile-web/src/services/chatImageAttachments.ts
new file mode 100644
index 0000000000..2b2562f42e
--- /dev/null
+++ b/src/mobile-web/src/services/chatImageAttachments.ts
@@ -0,0 +1,90 @@
+/** Model-ready image attachment projected from a remote transcript message. */
+export interface RemoteImageContext {
+ id: string;
+ data_url: string;
+ mime_type: string;
+ metadata: { name: string; source: string };
+}
+
+/** Attachment restored into the mobile composer. */
+export interface PendingChatImage {
+ name: string;
+ dataUrl: string;
+}
+
+/**
+ * Accept only canonical base64 image data URLs. The host may replay transcript
+ * data that was edited or truncated, so padding bits and quantum length are
+ * checked as well as the alphabet (RFC 4648 section 3.5) before the payload is
+ * forwarded to the model or decoded by the browser.
+ */
+export function normalizeValidImageDataUrl(
+ url: unknown,
+): { trimmedUrl: string; mimeType: string } | null {
+ if (typeof url !== 'string') return null;
+ const trimmedUrl = url.trim();
+ const match = trimmedUrl.match(/^data:(image\/[a-zA-Z0-9.+-]+);base64,([A-Za-z0-9+/=]+)$/);
+ if (!match) return null;
+ const mimeType = match[1];
+ const base64Data = match[2];
+ const isValid =
+ /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}[AEIMQUYcgkosw048]=|[A-Za-z0-9+/][AQgw]==|[A-Za-z0-9+/]{4})$/.test(
+ base64Data,
+ );
+ if (!isValid) return null;
+ return { trimmedUrl, mimeType };
+}
+
+/** Project valid transcript images into `send_message` image contexts. */
+export function extractValidImageContexts(
+ images: unknown,
+ prefix: string,
+): RemoteImageContext[] | undefined {
+ if (!Array.isArray(images) || images.length === 0) return undefined;
+ const valid: { data_url: string; mime_type: string; name: string }[] = [];
+ for (const img of images) {
+ try {
+ if (img && typeof img === 'object') {
+ const normalized = normalizeValidImageDataUrl(img.data_url);
+ if (normalized) {
+ valid.push({
+ data_url: normalized.trimmedUrl,
+ mime_type: normalized.mimeType,
+ name: typeof img.name === 'string' && img.name ? img.name : 'image',
+ });
+ }
+ }
+ } catch {
+ // Ignore corrupted attachment items.
+ }
+ }
+ if (valid.length === 0) return undefined;
+ return valid.map((item, idx) => ({
+ id: `${prefix}_${Date.now()}_${idx}`,
+ data_url: item.data_url,
+ mime_type: item.mime_type,
+ metadata: { name: item.name, source: 'remote' },
+ }));
+}
+
+/** Project valid transcript images into composer attachments. */
+export function extractValidPendingImages(images: unknown): PendingChatImage[] {
+ if (!Array.isArray(images) || images.length === 0) return [];
+ const result: PendingChatImage[] = [];
+ for (const img of images) {
+ try {
+ if (img && typeof img === 'object') {
+ const normalized = normalizeValidImageDataUrl(img.data_url);
+ if (normalized) {
+ result.push({
+ name: typeof img.name === 'string' && img.name ? img.name : 'image',
+ dataUrl: normalized.trimmedUrl,
+ });
+ }
+ }
+ } catch {
+ // Ignore corrupted attachment items.
+ }
+ }
+ return result;
+}
diff --git a/src/mobile-web/tests/browser-account.test.mjs b/src/mobile-web/tests/browser-account.test.mjs
index c72e3ebff5..e600a1ac96 100644
--- a/src/mobile-web/tests/browser-account.test.mjs
+++ b/src/mobile-web/tests/browser-account.test.mjs
@@ -9,7 +9,23 @@ import { LAN, OFFICIAL, RelayFixture, connected, disconnect, invitation, launchB
let browser;
let source;
before(async () => { source = await startSourceServer(); browser = await launchBrowser(); });
-after(async () => { await browser?.close(); await source?.close(); });
+after(async () => {
+ // Bound cleanup so a stuck browser or dev server cannot hang the runner,
+ // but never force the process exit: the test runner still owns the exit
+ // code and a forced zero would mask failed assertions.
+ try {
+ await Promise.race([
+ browser?.close(),
+ new Promise(resolve => setTimeout(resolve, 3000)),
+ ]);
+ } catch {}
+ try {
+ await Promise.race([
+ source?.close(),
+ new Promise(resolve => setTimeout(resolve, 3000)),
+ ]);
+ } catch {}
+});
for (const endpoint of [LAN, OFFICIAL]) {
test(`one login serves fresh tabs and reloads with one connected browser: ${endpoint}`, { timeout: 40_000 }, async () => {
@@ -185,7 +201,10 @@ test('a closed and reopened browser profile retains its login and controller key
assert.equal(relay.logins.length, 1);
assert.equal(relay.clients.size, 1);
assert.deepEqual(relay.errors, []);
- } finally { await persistent?.close(); await rm(profile, { recursive: true, force: true }); }
+ } finally {
+ await persistent?.close();
+ try { await rm(profile, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); } catch {}
+ }
});
test('legacy tab credentials migrate with their keys and navigation without reviving a signed-out account', { timeout: 40_000 }, async () => {
diff --git a/src/mobile-web/tests/helpers/browser-account-harness.mjs b/src/mobile-web/tests/helpers/browser-account-harness.mjs
index 0e0cf5004d..b81b9bc9f3 100644
--- a/src/mobile-web/tests/helpers/browser-account-harness.mjs
+++ b/src/mobile-web/tests/helpers/browser-account-harness.mjs
@@ -44,8 +44,13 @@ export async function launchBrowser(options = {}) {
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
'/usr/bin/google-chrome', '/usr/bin/google-chrome-stable', '/usr/bin/chromium', '/usr/bin/chromium-browser',
process.env.PROGRAMFILES && `${process.env.PROGRAMFILES}/Google/Chrome/Application/chrome.exe`,
+ process.env['PROGRAMFILES(X86)'] && `${process.env['PROGRAMFILES(X86)']}/Google/Chrome/Application/chrome.exe`,
+ process.env.LOCALAPPDATA && `${process.env.LOCALAPPDATA}/Google/Chrome/Application/chrome.exe`,
+ process.env.PROGRAMFILES && `${process.env.PROGRAMFILES}/Microsoft/Edge/Application/msedge.exe`,
+ process.env['PROGRAMFILES(X86)'] && `${process.env['PROGRAMFILES(X86)']}/Microsoft/Edge/Application/msedge.exe`,
+ process.env.LOCALAPPDATA && `${process.env.LOCALAPPDATA}/Microsoft/Edge/Application/msedge.exe`,
].find(path => path && existsSync(path));
- assert.ok(executablePath, 'Set PUPPETEER_EXECUTABLE_PATH to an installed Chrome/Chromium browser.');
+ assert.ok(executablePath, 'Set PUPPETEER_EXECUTABLE_PATH to an installed Chrome, Chromium, or Edge browser.');
return puppeteer.launch({ executablePath, headless: 'new',
// Fixture pages use remote-looking origins while their simulated Relay is loopback.
args: ['--no-sandbox', '--disable-dev-shm-usage', '--disable-background-timer-throttling',
@@ -64,7 +69,7 @@ export async function startSourceServer() {
sockets.handleUpgrade(request, socket, head, ws => fixture.socket(ws, url.searchParams.get('endpoint')));
});
await vite.listen();
- return { origin: `http://127.0.0.1:${vite.httpServer.address().port}`, close: async () => { for (const socket of sockets.clients) socket.terminate(); sockets.close(); realtimeFixtures.clear(); await vite.close(); } };
+ return { origin: `http://127.0.0.1:${vite.httpServer.address().port}`, close: async () => { for (const socket of sockets.clients) socket.terminate(); sockets.close(); realtimeFixtures.clear(); vite.httpServer?.closeAllConnections?.(); await vite.close(); } };
}
/** Real browser fetches and encrypted RPC envelopes; only Relay/host IO is simulated. */
diff --git a/src/mobile-web/tests/host-dialog-queue-browser.test.mjs b/src/mobile-web/tests/host-dialog-queue-browser.test.mjs
index e73433c7a1..31274dfed0 100644
--- a/src/mobile-web/tests/host-dialog-queue-browser.test.mjs
+++ b/src/mobile-web/tests/host-dialog-queue-browser.test.mjs
@@ -1,8 +1,10 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { fileURLToPath } from 'node:url';
+import { tmpdir } from 'node:os';
+import path from 'node:path';
import { launchBrowser, startSourceServer } from './helpers/browser-account-harness.mjs';
-const modulePath = '/@fs' + fileURLToPath(new URL('../../shared/dialog-queue/HostDialogQueue.ts', import.meta.url));
+const modulePath = '/@fs/' + fileURLToPath(new URL('../../shared/dialog-queue/HostDialogQueue.ts', import.meta.url)).replace(/\\/g, '/').replace(/^\/+/, '');
test('real IndexedDB retains an ambiguous submission after closing the browser page', {timeout:60000}, async () => {
const server=await startSourceServer();const browser=await launchBrowser();
@@ -50,7 +52,7 @@ test('mobile running composer keeps send and stop independently available alongs
assert.ok((await page.evaluate(()=>window.queueFixture.calls)).includes('send'));
assert.ok(!(await page.evaluate(()=>window.queueFixture.calls)).includes('stop'));
assert.equal(await page.$eval('body', body=>body.scrollWidth<=window.innerWidth),true);
- await page.screenshot({path:'/tmp/mobile-host-message-queue.png',fullPage:true});
+ await page.screenshot({path:path.join(tmpdir(),'mobile-host-message-queue.png'),fullPage:true});
}finally{await browser.close();await server.close();}
});
@@ -113,7 +115,7 @@ test('queue stays above the measured composer across phone, keyboard-height and
assert.equal(geometry.plusWidth,44);
for(const edge of ['top','bottom','left']) assert.ok(Math.abs(geometry[edge]-geometry.right)<1,JSON.stringify(geometry));
assert.ok(Math.abs(geometry.textLeft-geometry.iconLeft)<1,JSON.stringify(geometry));
- await page.screenshot({path:'/tmp/mobile-queue-aligned.png',fullPage:true});
+ await page.screenshot({path:path.join(tmpdir(),'mobile-queue-aligned.png'),fullPage:true});
await page.click('[aria-label="排队消息说明"]');
await page.waitForSelector('.host-message-queue__help');
await page.click('[aria-label="排队消息说明"]');
diff --git a/src/mobile-web/tests/host-stream.test.mjs b/src/mobile-web/tests/host-stream.test.mjs
index 4d4cc239c4..c7917842b7 100644
--- a/src/mobile-web/tests/host-stream.test.mjs
+++ b/src/mobile-web/tests/host-stream.test.mjs
@@ -273,3 +273,35 @@ test('formal steering turns render once with stable message ownership after hist
assert.equal(live[3].content,'New answer');
assert.deepEqual(render([...records].reverse().concat(records)),live,'newest-first reconnect and duplicate replay retain the same transcript');
});
+
+
+test('streamed user turns project attachment pixels for transcript and draft restore', async () => {
+ async function load(path) {
+ const source = await readFile(new URL(path, import.meta.url), 'utf8');
+ const compiled = ts.transpileModule(source, {compilerOptions:{module:ts.ModuleKind.ESNext,target:ts.ScriptTarget.ES2022}}).outputText;
+ return import(`data:text/javascript;base64,${Buffer.from(compiled).toString('base64')}`);
+ }
+ const {presentSessionTurn} = await load('../src/services/SessionRecordPresentation.ts');
+ const metadata = {images: [
+ {id: 'img-1', name: 'screen.png', mime_type: 'image/png', data_url: ' data:image/png;base64,YQ== '},
+ {id: 'img-2', name: 'path-only.png', mime_type: 'image/png'},
+ null,
+ {data_url: 'data:image/jpeg;base64,YWI='},
+ ]};
+ const [user] = presentSessionTurn({
+ turnId: 'turn-a', turnIndex: 3, status: 'completed', timestamp: 1,
+ userMessage: {id: 'user-a', content: '', timestamp: 1, metadata},
+ modelRounds: [],
+ });
+ assert.deepEqual(user.images, [
+ {name: 'screen.png', data_url: 'data:image/png;base64,YQ=='},
+ {name: 'image', data_url: 'data:image/jpeg;base64,YWI='},
+ ], 'only attachments whose pixels arrived reach the composer');
+ assert.equal(user.metadata, metadata, 'protocol metadata stays intact for other projections');
+ const [withoutImages] = presentSessionTurn({
+ turnId: 'turn-b', turnIndex: 4, status: 'completed', timestamp: 2,
+ userMessage: {id: 'user-b', content: 'hello', timestamp: 2},
+ modelRounds: [],
+ });
+ assert.equal(withoutImages.images, undefined);
+});
diff --git a/src/mobile-web/tests/image-compressor.test.mjs b/src/mobile-web/tests/image-compressor.test.mjs
index 4826a12349..0d8f941d07 100644
--- a/src/mobile-web/tests/image-compressor.test.mjs
+++ b/src/mobile-web/tests/image-compressor.test.mjs
@@ -56,3 +56,87 @@ test('unreadable photos fail before upload instead of using raw bytes as a fallb
browserImages({ invalid: true });
await assert.rejects(compressImageDataUrl('data:image/heic;base64,YQ==', 'camera.heic'));
});
+
+const attachmentsSource = await readFile(
+ new URL('../src/services/chatImageAttachments.ts', import.meta.url),
+ 'utf8',
+);
+const attachmentsCode = ts.transpileModule(attachmentsSource, {
+ compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2020 },
+}).outputText;
+const {
+ normalizeValidImageDataUrl,
+ extractValidImageContexts,
+ extractValidPendingImages,
+} = await import(`data:text/javascript;base64,${Buffer.from(attachmentsCode).toString('base64')}`);
+
+test('image data URLs must use canonical RFC 4648 base64', () => {
+ assert.deepEqual(normalizeValidImageDataUrl('data:image/png;base64,YQ=='), {
+ trimmedUrl: 'data:image/png;base64,YQ==',
+ mimeType: 'image/png',
+ });
+ assert.deepEqual(normalizeValidImageDataUrl(' data:image/jpeg;base64,YWI= '), {
+ trimmedUrl: 'data:image/jpeg;base64,YWI=',
+ mimeType: 'image/jpeg',
+ });
+ assert.equal(normalizeValidImageDataUrl('data:image/webp;base64,YWJj')?.mimeType, 'image/webp');
+ assert.equal(normalizeValidImageDataUrl('data:image/svg+xml;base64,PHN2Zy8+')?.mimeType, 'image/svg+xml');
+
+ for (const invalid of [
+ undefined,
+ null,
+ 42,
+ '',
+ 'data:image/png;base64,',
+ 'data:image/png;base64,YQ',
+ 'data:image/png;base64,YR==', // non-zero padding bits
+ 'data:image/png;base64,YQ=',
+ 'data:image/png;base64,Y Q==',
+ 'data:text/plain;base64,YQ==',
+ 'data:image/png,YQ==',
+ 'data:image/png;base64,YQ--',
+ ]) {
+ assert.equal(normalizeValidImageDataUrl(invalid), null, String(invalid));
+ }
+});
+
+test('transcript images project into model contexts without corrupted entries', () => {
+ const images = [
+ { name: 'screen.png', data_url: 'data:image/png;base64,YQ==' },
+ { name: '', data_url: ' data:image/jpeg;base64,YWI= ' },
+ { name: 'broken.png', data_url: 'data:image/png;base64,YR==' },
+ null,
+ 'not-an-object',
+ { name: 'missing.png' },
+ ];
+ const contexts = extractValidImageContexts(images, 'mobile_edit');
+ assert.deepEqual(contexts?.map(({ id, ...rest }) => rest), [
+ {
+ data_url: 'data:image/png;base64,YQ==',
+ mime_type: 'image/png',
+ metadata: { name: 'screen.png', source: 'remote' },
+ },
+ {
+ data_url: 'data:image/jpeg;base64,YWI=',
+ mime_type: 'image/jpeg',
+ metadata: { name: 'image', source: 'remote' },
+ },
+ ]);
+ assert.match(contexts[0].id, /^mobile_edit_\d+_0$/);
+ assert.match(contexts[1].id, /^mobile_edit_\d+_1$/);
+ assert.equal(extractValidImageContexts([], 'mobile_edit'), undefined);
+ assert.equal(extractValidImageContexts([{ data_url: 'data:image/png;base64,YR==' }], 'x'), undefined);
+});
+
+test('rollback drafts restore only valid images', () => {
+ assert.deepEqual(
+ extractValidPendingImages([
+ { name: 'screen.png', data_url: 'data:image/png;base64,YQ==' },
+ { name: 'broken.png', data_url: 'data:image/png;base64,YR==' },
+ null,
+ ]),
+ [{ name: 'screen.png', dataUrl: 'data:image/png;base64,YQ==' }],
+ );
+ assert.deepEqual(extractValidPendingImages(undefined), []);
+ assert.deepEqual(extractValidPendingImages('not-an-array'), []);
+});
diff --git a/src/mobile-web/tests/session-stream-browser.test.mjs b/src/mobile-web/tests/session-stream-browser.test.mjs
index 63e1af4d01..c0858ab15f 100644
--- a/src/mobile-web/tests/session-stream-browser.test.mjs
+++ b/src/mobile-web/tests/session-stream-browser.test.mjs
@@ -84,13 +84,13 @@ test('an older host without host streams is reported as unsupported instead of r
const relay=new RelayFixture();relay.legacyHost=true;
const page=await relay.page(context,source.origin);
await signIn(page);await connected(page);
- const module=fileURLToPath(new URL('../../shared/relay-transport/HostStream.ts',import.meta.url));
+ const module='/@fs/'+fileURLToPath(new URL('../../shared/relay-transport/HostStream.ts',import.meta.url)).replace(/\\/g, '/').replace(/^\/+/, '');
const result=await page.evaluate(async path=>{
const {getBrowserAccountStore,releaseBrowserAccount}=await import('/src/services/BrowserAccountStore.ts');
const {currentRelayUrl}=await import('/src/services/pairingLink.ts');
const {RelayHttpClient}=await import('/src/services/RelayHttpClient.ts');
const {RemoteSessionManager}=await import('/src/services/RemoteSessionManager.ts');
- const {UNSUPPORTED_HOST_MESSAGE}=await import('/@fs'+path);
+ const {UNSUPPORTED_HOST_MESSAGE}=await import(path);
const saved=await getBrowserAccountStore(currentRelayUrl()).read();
const client=new RelayHttpClient(currentRelayUrl(),{token:saved.session.token,masterKey:saved.session.masterKey,userId:saved.session.userId,deviceId:saved.controllerDeviceId});
releaseBrowserAccount(saved);
diff --git a/src/mobile-web/tests/workspace-identity.test.mjs b/src/mobile-web/tests/workspace-identity.test.mjs
index 459ec659c4..0b11719f0e 100644
--- a/src/mobile-web/tests/workspace-identity.test.mjs
+++ b/src/mobile-web/tests/workspace-identity.test.mjs
@@ -108,6 +108,7 @@ const controlIdentity = await moduleUrl('../src/services/controlClientIdentity.t
const hostStream = await moduleUrl('../../shared/relay-transport/HostStream.ts');
const managerUrl = await moduleUrl('../src/services/RemoteSessionManager.ts', {
'../../../shared/agent-harness/wire': agentWire,
+ '../../../shared/dialog-queue/HostDialogQueue': 'data:text/javascript,export class HostDialogQueue {}',
'../../../shared/relay-transport/HostStream': hostStream,
'./controlClientIdentity': controlIdentity,
'./SessionSynchronizer': 'data:text/javascript,export class SessionSynchronizer {}',
diff --git a/src/web-ui/package.json b/src/web-ui/package.json
index 45ebc501a9..b2b49da9d3 100644
--- a/src/web-ui/package.json
+++ b/src/web-ui/package.json
@@ -27,8 +27,8 @@
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"type-check": "tsc --noEmit",
- "test": "vitest",
- "test:run": "vitest run --maxWorkers=50%"
+ "test": "node scripts/run-vitest.mjs",
+ "test:run": "node scripts/run-vitest.mjs run --maxWorkers=50%"
},
"dependencies": {
"@openbitfun/flow-chat-presentation": "workspace:*",
diff --git a/src/web-ui/scripts/run-vitest.mjs b/src/web-ui/scripts/run-vitest.mjs
new file mode 100644
index 0000000000..654b7429e5
--- /dev/null
+++ b/src/web-ui/scripts/run-vitest.mjs
@@ -0,0 +1,32 @@
+#!/usr/bin/env node
+import { spawn } from 'node:child_process';
+import path from 'node:path';
+import { createRequire } from 'node:module';
+
+// Node 25+ exposes its own webstorage globals, which shadow jsdom's
+// localStorage/sessionStorage in Vitest environments. Append the disable flag
+// instead of replacing NODE_OPTIONS so the caller's heap or tracing flags stay
+// intact.
+const WEBSTORAGE_FLAG = '--no-experimental-webstorage';
+const existing = (process.env.NODE_OPTIONS ?? '').trim();
+if (!existing.split(/\s+/).includes(WEBSTORAGE_FLAG)) {
+ process.env.NODE_OPTIONS = [existing, WEBSTORAGE_FLAG].filter(Boolean).join(' ');
+}
+
+const require = createRequire(import.meta.url);
+const vitestPackagePath = require.resolve('vitest/package.json');
+const vitestBin = path.join(
+ path.dirname(vitestPackagePath),
+ require('vitest/package.json').bin.vitest,
+);
+const child = spawn(process.execPath, [vitestBin, ...process.argv.slice(2)], {
+ env: process.env,
+ stdio: 'inherit',
+});
+child.on('exit', (code, signal) => {
+ if (signal) {
+ process.kill(process.pid, signal);
+ return;
+ }
+ process.exit(code ?? 1);
+});