Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,185 @@ test("Runtime Host Artifact IPC preserves previews and streams complete exports"
}
});

test("HTML Artifact materializes and opens with the operating system default app", async () => {
const root = await mkdtemp(join(tmpdir(), "maka-host-html-artifact-ipc-"));
const presentationRoot = join(root, "presentations");
const content = Buffer.from("<!doctype html><button>Run interaction</button>");
const handlers = new Map<string, Handler>();
const openedPaths: string[] = [];
const artifact = previewArtifact({
name: "interactive.html",
kind: "html",
mimeType: "text/html",
sizeBytes: content.byteLength,
});

try {
registerRuntimeHostArtifactsIpc({
uiLocale: () => "en" as const,
ipcMain: {
handle: (channel, handler) => handlers.set(channel, handler as Handler),
},
client: {
hostEpoch: "host-1",
async getArtifact() {
return artifact;
},
async streamArtifact(
_sessionId: string,
_artifactId: string,
writeChunk: (chunk: Uint8Array) => Promise<void>,
) {
await writeChunk(content);
return content.byteLength;
},
} as never,
mainWindowController: {} as never,
showItemInFolder: () => {
throw new Error("HTML artifacts must use openPath");
},
openPath: async (path) => {
openedPaths.push(path);
return "";
},
presentationRoot,
});

const open = handlers.get("app:openArtifactPath");
assert.ok(open);
assert.deepEqual(await open({}, "session-1", "artifact-1"), {
ok: true,
opened: "interactive.html",
});
assert.equal(openedPaths.length, 1);
assert.equal(await readFile(openedPaths[0]!, "utf8"), content.toString("utf8"));
} finally {
await rm(root, { recursive: true, force: true });
}
});

test("HTML Artifact reveal uses Finder without invoking the default app", async () => {
const root = await mkdtemp(join(tmpdir(), "maka-host-html-reveal-ipc-"));
const presentationRoot = join(root, "presentations");
const content = Buffer.from("<!doctype html><button>Run interaction</button>");
const handlers = new Map<string, Handler>();
const revealedPaths: string[] = [];
const artifact = previewArtifact({
name: "interactive.html",
kind: "html",
mimeType: "text/html",
sizeBytes: content.byteLength,
});

try {
registerRuntimeHostArtifactsIpc({
uiLocale: () => "en" as const,
ipcMain: {
handle: (channel, handler) => handlers.set(channel, handler as Handler),
},
client: {
hostEpoch: "host-1",
async getArtifact() {
return artifact;
},
async streamArtifact(_sessionId: string, _artifactId: string, writeChunk: (chunk: Uint8Array) => Promise<void>) {
await writeChunk(content);
return content.byteLength;
},
} as never,
mainWindowController: {} as never,
showItemInFolder: (path) => revealedPaths.push(path),
openPath: async () => {
throw new Error("reveal must not invoke openPath");
},
presentationRoot,
});

const reveal = handlers.get("app:showArtifactInFolder");
assert.ok(reveal);
assert.deepEqual(await reveal({}, "session-1", "artifact-1"), {
ok: true,
opened: "interactive.html",
});
assert.equal(revealedPaths.length, 1);
assert.equal(await readFile(revealedPaths[0]!, "utf8"), content.toString("utf8"));
} finally {
await rm(root, { recursive: true, force: true });
}
});

for (const [label, openPath] of [
["returns an error", async () => "default app unavailable"],
["rejects", async () => { throw new Error("launcher unavailable"); }],
] as const) {
test(`HTML Artifact reports default-app failure when opener ${label}`, async () => {
const root = await mkdtemp(join(tmpdir(), "maka-host-html-open-failure-"));
const presentationRoot = join(root, "presentations");
const content = Buffer.from("<!doctype html><p>failure test</p>");
const handlers = new Map<string, Handler>();
let revealCalls = 0;
const artifact = previewArtifact({
name: "interactive.html",
kind: "html",
mimeType: "text/html",
sizeBytes: content.byteLength,
});
try {
registerRuntimeHostArtifactsIpc({
uiLocale: () => "en" as const,
ipcMain: { handle: (channel, handler) => handlers.set(channel, handler as Handler) },
client: {
hostEpoch: "host-1",
async getArtifact() { return artifact; },
async streamArtifact(_sessionId: string, _artifactId: string, writeChunk: (chunk: Uint8Array) => Promise<void>) {
await writeChunk(content);
return content.byteLength;
},
} as never,
mainWindowController: {} as never,
showItemInFolder: () => { revealCalls += 1; },
openPath,
presentationRoot,
});
const open = handlers.get("app:openArtifactPath");
assert.ok(open);
assert.deepEqual(await open({}, "session-1", "artifact-1"), { ok: false, reason: "open-failed" });
assert.equal(revealCalls, 0);
} finally {
await rm(root, { recursive: true, force: true });
}
});
}

test("HTML Artifact does not invoke an opener when materialization fails", async () => {
const root = await mkdtemp(join(tmpdir(), "maka-host-html-materialize-failure-"));
const handlers = new Map<string, Handler>();
const content = Buffer.from("<!doctype html><p>failure test</p>");
const artifact = previewArtifact({ name: "interactive.html", kind: "html", mimeType: "text/html", sizeBytes: content.byteLength });
let openCalls = 0;
try {
registerRuntimeHostArtifactsIpc({
uiLocale: () => "en" as const,
ipcMain: { handle: (channel, handler) => handlers.set(channel, handler as Handler) },
client: {
hostEpoch: "host-1",
async getArtifact() { return artifact; },
async streamArtifact() { throw new Error("source unavailable"); },
} as never,
mainWindowController: {} as never,
showItemInFolder: () => { throw new Error("must not reveal failed materialization"); },
openPath: async () => { openCalls += 1; return ""; },
presentationRoot: join(root, "presentations"),
});
const open = handlers.get("app:openArtifactPath");
assert.ok(open);
assert.deepEqual(await open({}, "session-1", "artifact-1"), { ok: false, reason: "open-failed" });
assert.equal(openCalls, 0);
} finally {
await rm(root, { recursive: true, force: true });
}
});

test("Attachment byte IPC rejects preview-ineligible metadata before streaming", async () => {
for (const [overrides, reason] of [
[{ id: "artifact-large", sizeBytes: 2 * 1024 * 1024 + 1 }, "too_large"],
Expand Down
99 changes: 98 additions & 1 deletion apps/desktop/src/main/__tests__/workbar-services-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,19 @@
*/

import { strict as assert } from 'node:assert';
import { describe, it } from 'node:test';
import { afterEach, describe, it, test } from 'node:test';
import { parseHTML } from 'linkedom';
import { act, createElement } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import type { ArtifactDescriptor } from '@maka/core/artifacts';
import { LocaleProvider } from '@maka/ui';
import type { MakaBridge } from '../../preload/bridge-contract.js';
import { createDesktopWorkbarServices } from '../../renderer/platform/desktop/create-workbar-services.js';
import {
ArtifactPreview,
createFakeWorkbarServices,
WorkbarServicesProvider,
} from '../../renderer/features/workbar/testing.js';

type RecordedCall = { name: string; args: unknown[] };

Expand Down Expand Up @@ -136,6 +146,7 @@ describe('createDesktopWorkbarServices', () => {
await services.artifacts.readBinary('s', 'a');
await services.artifacts.delete('s', 'a');
await services.artifacts.openPath('s', 'a');
await services.artifacts.showInFolder('s', 'a');
await services.artifacts.saveAs('s', 'a');

await services.inspector.trace('s', 'cursor-1');
Expand Down Expand Up @@ -206,6 +217,7 @@ describe('createDesktopWorkbarServices', () => {
'artifacts.readBinary',
'artifacts.delete',
'app.openArtifactPath',
'app.showArtifactInFolder',
'app.saveArtifactAs',
'inspector.trace',
'inspector.summary',
Expand Down Expand Up @@ -251,3 +263,88 @@ describe('createDesktopWorkbarServices', () => {
]);
});
});

let mountedRoot: Root | undefined;
const originalRendererGlobals = {
document: globalThis.document,
window: globalThis.window,
HTMLElement: globalThis.HTMLElement,
HTMLIFrameElement: globalThis.HTMLIFrameElement,
Node: globalThis.Node,
matchMedia: globalThis.matchMedia,
requestAnimationFrame: globalThis.requestAnimationFrame,
cancelAnimationFrame: globalThis.cancelAnimationFrame,
IS_REACT_ACT_ENVIRONMENT: (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT,
};

afterEach(async () => {
if (mountedRoot) await act(() => mountedRoot?.unmount());
mountedRoot = undefined;
Object.assign(globalThis, originalRendererGlobals);
});

test('HTML preview failure CTA uses the Finder reveal callback', async () => {
const { document, window } = parseHTML('<div id="root"></div>');
Object.assign(globalThis, {
document,
window,
HTMLElement: window.HTMLElement,
HTMLIFrameElement: window.HTMLIFrameElement ?? class HTMLIFrameElement {},
Node: window.Node,
matchMedia: () => ({ matches: false, addEventListener() {}, removeEventListener() {} }),
requestAnimationFrame: (callback: FrameRequestCallback) => setTimeout(callback, 0),
cancelAnimationFrame: (handle: number) => clearTimeout(handle),
IS_REACT_ACT_ENVIRONMENT: true,
});
const container = document.querySelector('#root');
assert.ok(container);
const root = createRoot(container);
mountedRoot = root;
let revealCalls = 0;
const defaults = createFakeWorkbarServices();
const services = createFakeWorkbarServices({
artifacts: {
...defaults.artifacts,
readText: async () => ({ ok: false, reason: 'read_failed' as const }),
},
});
const record = {
id: 'artifact-1',
sessionId: 'session-1',
turnId: 'turn-1',
createdAt: 1,
name: 'interactive.html',
kind: 'html',
sizeBytes: 42,
mimeType: 'text/html',
source: 'tool_result',
} as ArtifactDescriptor;

await act(async () => {
root.render(createElement(
LocaleProvider,
{
locale: 'en',
children: createElement(
WorkbarServicesProvider,
{ services },
createElement(ArtifactPreview, {
record,
onShowInFolder: () => { revealCalls += 1; },
}),
),
},
));
await Promise.resolve();
});

const button = [...document.querySelectorAll('button')].find((candidate) =>
candidate.textContent?.includes('Show in Finder'),
);
assert.ok(button, 'failure card should expose a Finder action');
await act(async () => {
button.dispatchEvent(new window.Event('click', { bubbles: true }));
await Promise.resolve();
});
assert.equal(revealCalls, 1);
});
43 changes: 37 additions & 6 deletions apps/desktop/src/main/runtime-host-artifacts-ipc-main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ interface RuntimeHostArtifactsIpcDeps {
readonly client: DesktopRuntimeHostClient;
readonly mainWindowController: ReturnType<typeof createMainWindowController>;
readonly showItemInFolder: (path: string) => void;
readonly openPath?: (path: string) => Promise<string>;
readonly presentationRoot?: string;
}

Expand Down Expand Up @@ -82,6 +83,20 @@ export function registerRuntimeHostArtifactsIpc(
deps.client.deleteArtifact(sessionId, artifactId),
);
registerRuntimeHostAttachmentPreviewIpc(deps);
const materializePresentationArtifact = async (
sessionId: string,
artifactId: string,
artifact: Awaited<ReturnType<DesktopRuntimeHostClient['getArtifact']>>,
): Promise<string> => {
if (!artifact) throw new Error('Artifact is missing');
const path = join(
presentationRoot,
sessionId,
`${artifactId}-${sanitizeArtifactName(artifact.name)}`,
);
await materializeArtifact(deps.client, sessionId, artifactId, path, artifact.sizeBytes);
return path;
};
deps.ipcMain.handle(
"app:openArtifactPath",
async (_event, sessionId: string, artifactId: string) => {
Expand All @@ -90,12 +105,28 @@ export function registerRuntimeHostArtifactsIpc(
return { ok: false as const, reason: "missing" as const };
}
try {
const path = join(
presentationRoot,
sessionId,
`${artifactId}-${sanitizeArtifactName(artifact.name)}`,
);
await materializeArtifact(deps.client, sessionId, artifactId, path, artifact.sizeBytes);
const path = await materializePresentationArtifact(sessionId, artifactId, artifact);
if (artifact.kind === 'html' && deps.openPath) {
const error = await deps.openPath(path);
if (error) return { ok: false as const, reason: "open-failed" as const };
} else {
deps.showItemInFolder(path);
}
return { ok: true as const, opened: artifact.name };
} catch {
return { ok: false as const, reason: "open-failed" as const };
}
},
);
deps.ipcMain.handle(
"app:showArtifactInFolder",
async (_event, sessionId: string, artifactId: string) => {
const artifact = await deps.client.getArtifact(sessionId, artifactId);
if (!artifact) {
return { ok: false as const, reason: "missing" as const };
}
try {
const path = await materializePresentationArtifact(sessionId, artifactId, artifact);
deps.showItemInFolder(path);
return { ok: true as const, opened: artifact.name };
} catch {
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/main/runtime-host-boot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1640,6 +1640,7 @@ function registerHostClientIpc(
client,
mainWindowController,
showItemInFolder: (path) => shell.showItemInFolder(path),
openPath: (path) => shell.openPath(path),
});
registerRuntimeHostOAuthIpc({
ipcMain: scopedIpc,
Expand Down
Loading