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
64 changes: 59 additions & 5 deletions src/studio/__tests__/tabs/ExportTab.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,16 @@ let recompute: StudioRecomputeResult = {
recomputeMs: 0,
};

const mockCode = { code: 'return box(10, 10, 10);' };

vi.mock('../../hooks/useRecomputeResult', () => ({
useRecomputeResult: () => recompute,
}));

vi.mock('../../context/CodeContext', () => ({
useCode: () => mockCode,
}));

// S1: ExportTab now routes through the apiBase helper, which calls
// supabase.auth.getSession(). Stub the Supabase client so the test stays
// behavior-equivalent to today (unsigned-in → relative URL).
Expand All @@ -36,6 +42,7 @@ beforeEach(() => {
diagnostics: [],
recomputeMs: 0,
};
mockCode.code = 'return box(10, 10, 10);';
Object.defineProperty(window, 'location', {
configurable: true,
value: { ...window.location, search: '?script=examples/foo.kcad.ts' },
Expand Down Expand Up @@ -165,23 +172,50 @@ describe('ExportTab', () => {
HTMLAnchorElement.prototype.click = originalClick;
});

it('shows an error when the script param is missing', async () => {
it('POSTs current editor source when the script param is missing', async () => {
Object.defineProperty(window, 'location', {
configurable: true,
value: { ...window.location, search: '' },
});
recompute = { ...recompute, geometries: [{ faces: [] }] };
mockCode.code = 'return cylinder(5, 20);';

const blob = new Blob([new Uint8Array([1, 2, 3])], { type: 'model/stl' });
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
blob: async () => blob,
headers: new Headers({ 'content-disposition': 'attachment; filename="studio-export.stl"' }),
});
vi.stubGlobal('fetch', fetchMock);

const clickSpy = vi.fn();
const originalClick = HTMLAnchorElement.prototype.click;
HTMLAnchorElement.prototype.click = clickSpy;

const { ExportTab } = await import('../../tabs/ExportTab');
render(<ExportTab />);
fireEvent.click(screen.getByTestId('export-stl'));

await waitFor(() => {
expect(screen.getByTestId('export-tab-error')).toBeDefined();
expect(fetchMock).toHaveBeenCalledTimes(1);
});
expect(fetchMock).toHaveBeenCalledWith(
'/__kernelcad/export?format=stl',
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ source: 'return cylinder(5, 20);' }),
}),
);
expect(clickSpy).toHaveBeenCalled();
expect(screen.queryByTestId('export-tab-error')).toBeNull();

HTMLAnchorElement.prototype.click = originalClick;
});

it('fetches the export endpoint with the right query and triggers a download', async () => {
it('POSTs editor source even when a ?script= param is present (exports live edits)', async () => {
recompute = { ...recompute, geometries: [{ faces: [] }] };
mockCode.code = 'return box(1, 2, 3); // edited';

const blob = new Blob([new Uint8Array([1, 2, 3])], { type: 'model/stl' });
const fetchMock = vi.fn().mockResolvedValue({
Expand All @@ -204,11 +238,31 @@ describe('ExportTab', () => {
expect(fetchMock).toHaveBeenCalledTimes(1);
});
expect(fetchMock).toHaveBeenCalledWith(
'/__kernelcad/export?script=examples%2Ffoo.kcad.ts&format=stl',
expect.objectContaining({ headers: {} }),
'/__kernelcad/export?format=stl',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ source: 'return box(1, 2, 3); // edited' }),
}),
);
expect(clickSpy).toHaveBeenCalled();

HTMLAnchorElement.prototype.click = originalClick;
});

it('shows an error when the editor has no source', async () => {
Object.defineProperty(window, 'location', {
configurable: true,
value: { ...window.location, search: '' },
});
recompute = { ...recompute, geometries: [{ faces: [] }] };
mockCode.code = ' ';

const { ExportTab } = await import('../../tabs/ExportTab');
render(<ExportTab />);
fireEvent.click(screen.getByTestId('export-stl'));
await waitFor(() => {
expect(screen.getByTestId('export-tab-error')).toBeDefined();
});
expect(screen.getByTestId('export-tab-error').textContent).toMatch(/script source/i);
});
});
4 changes: 2 additions & 2 deletions src/studio/api/apiBase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@
// - Hosted: `base = VITE_API_BASE_URL` (= https://api.kernelcad.com, the Hetzner
// backend, direct origin — NOT CF-proxied). The `/__kernelcad` prefix is KEPT,
// because that is exactly where the backend mounts every route (mesh, session,
// params, transforms, events, animation-bake, source). Signed-in calls add the
// Supabase JWT as a bearer header.
// params, transforms, events, animation-bake, source, export). Signed-in calls
// add the Supabase JWT as a bearer header.
//
// History / why no `/api/v1`: an earlier draft sent signed-in calls to
// `app.kernelcad.com/api/v1/*` and stripped the `/__kernelcad` prefix, relying
Expand Down
37 changes: 25 additions & 12 deletions src/studio/tabs/ExportTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@
import { useCallback, useState } from 'react';
import { Download, Loader2 } from 'lucide-react';
import { useRecomputeResult } from '../hooks/useRecomputeResult';
import { useCode } from '../context/CodeContext';
import { apiCall, rewritePath } from '../api/apiBase';
import { shouldUseHostedMesh } from '../scriptSource';
import type { JSX } from 'react';

// Studio Export tab. Slice 1.4 + Slice A export-trio.
Expand All @@ -14,6 +16,10 @@ import type { JSX } from 'react';
// targets: stl, step, dxf, 3mf, glb. The middleware threads `format`
// verbatim through to runAndExport.
//
// Export always POSTs the current editor source so it works without a
// `?script=` deep-link and reflects live edits (not just the on-disk
// example). The server accepts the same body shape as POST /__kernelcad/mesh.
//
// Visibility is adaptive: ExportTab is only rendered by Inspector when
// the recompute result has at least one geometry. See
// src/studio/logic/adaptiveTabs.ts. DXF additionally requires at least
Expand All @@ -38,13 +44,9 @@ const FORMATS: ReadonlyArray<FormatDescriptor> = [
{ id: 'glb', label: 'GLB', help: 'Web / AR viewer; PBR materials' },
];

function getCurrentScriptParam(): string | null {
if (typeof window === 'undefined') return null;
return new URLSearchParams(window.location.search).get('script');
}

export function ExportTab(): JSX.Element {
const { geometries } = useRecomputeResult();
const { code } = useCode();
const [pending, setPending] = useState<ExportFormat | null>(null);
const [error, setError] = useState<string | null>(null);

Expand All @@ -60,19 +62,30 @@ export function ExportTab(): JSX.Element {

const handleExport = useCallback(async (format: ExportFormat) => {
setError(null);
const script = getCurrentScriptParam();
if (!script) {
setError('Export requires the studio to be loaded from a script URL (?script=…).');
const source = code.trim();
if (!source) {
setError('Export requires script source in the editor.');
return;
}
setPending(format);
try {
const { base, headers } = await apiCall();
// Hosted static app has no same-origin /__kernelcad/* middleware.
// meshSourceHosted uses VITE_API_BASE_URL even when unsigned-in;
// mirror that so Export works without a Supabase session.
const effectiveBase = base
|| (shouldUseHostedMesh()
? (import.meta.env.VITE_API_BASE_URL as string | undefined) ?? ''
: '');
const url = rewritePath(
`/__kernelcad/export?script=${encodeURIComponent(script)}&format=${format}`,
base,
`/__kernelcad/export?format=${format}`,
effectiveBase,
);
const response = await fetch(url, { headers });
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...headers },
body: JSON.stringify({ source }),
});
if (!response.ok) {
const payload = await response.json().catch(() => ({}));
throw new Error(typeof payload?.error === 'string' ? payload.error : response.statusText);
Expand All @@ -96,7 +109,7 @@ export function ExportTab(): JSX.Element {
} finally {
setPending(null);
}
}, []);
}, [code]);

if (geometries.length === 0) {
return (
Expand Down
68 changes: 53 additions & 15 deletions vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -390,14 +390,7 @@ function kernelCadMeshEndpoint(): Plugin {
server.middlewares.use('/__kernelcad/export', async (req, res) => {
try {
const url = new URL(req.url ?? '', 'http://localhost');
const scriptPath = resolveExampleScript(url.searchParams.get('script'));
const formatParam = url.searchParams.get('format');
if (!scriptPath) {
res.statusCode = 400;
res.setHeader('content-type', 'application/json');
res.end(JSON.stringify({ error: 'script must be a repo examples/*.kcad.ts file' }));
return;
}
// Slice A export-trio: widened from {stl, step} to the five-format
// set runAndExport now dispatches. The reserved urdf/srdf/sdf-gazebo
// slots intentionally stay out of the Studio UI — they ship in a
Expand All @@ -415,18 +408,63 @@ function kernelCadMeshEndpoint(): Plugin {
return;
}

const [{ readFile }, { runAndExport }, { dirname, basename }] = await Promise.all([
import('node:fs/promises'),
import('./src/agent/script-runtime/export'),
import('node:path'),
]);
const code = await readFile(scriptPath, 'utf-8');
const fileName = basename(scriptPath);
// POST { source } exports ARBITRARY edited editor code (Studio
// without ?script=, or live edits). GET ?script= keeps the
// curated-example path for deep-links / tooling.
const isPost = (req.method ?? 'GET').toUpperCase() === 'POST';
let code: string;
let fileName: string;
let scriptDir: string;

if (isPost) {
const chunks: Buffer[] = [];
for await (const chunk of req) chunks.push(chunk as Buffer);
let parsedBody: { source?: unknown };
try {
parsedBody = JSON.parse(Buffer.concat(chunks).toString('utf-8') || '{}');
} catch {
res.statusCode = 400;
res.setHeader('content-type', 'application/json');
res.end(JSON.stringify({ error: 'POST body must be JSON { source: string }' }));
return;
}
if (typeof parsedBody.source !== 'string' || parsedBody.source.trim().length === 0) {
res.statusCode = 400;
res.setHeader('content-type', 'application/json');
res.end(JSON.stringify({ error: 'POST body must include a non-empty "source" string' }));
return;
}
code = parsedBody.source;
fileName = 'studio-export.kcad.ts';
// Relative asset paths resolve the same way as mesh POST.
scriptDir = resolve(repoRoot, 'examples');
} else {
const scriptPath = resolveExampleScript(url.searchParams.get('script'));
if (!scriptPath) {
res.statusCode = 400;
res.setHeader('content-type', 'application/json');
res.end(JSON.stringify({
error: 'missing script query parameter (or POST { source })',
}));
return;
}
const [{ readFile }, { dirname, basename }] = await Promise.all([
import('node:fs/promises'),
import('node:path'),
]);
code = await readFile(scriptPath, 'utf-8');
fileName = basename(scriptPath);
scriptDir = dirname(scriptPath);
}

ensureOcctShims();

const { runAndExport } = await import('./src/agent/script-runtime/export');
const result = await runAndExport({
code,
fileName,
format: formatParam as StudioFormat,
scriptDir: dirname(scriptPath),
scriptDir,
});

if (result.bytes.length === 0) {
Expand Down
Loading