import { expect, test } from '@playwright/test';
import type { MathJSLabExecutor, MathJSLabExecutionResult } from 'mathjslab-markdown/mathjslab';
declare global {
interface Window {
mathJSLabWorkerTest: {
executor: MathJSLabExecutor;
viewer: HTMLElement & { markdown: string };
effects: Array<{ type: string; milliseconds?: number; reference?: string }>;
workerCount: number;
probe(): Promise<{ interpreter: boolean; plots: boolean }>;
nextEffect(): Promise<{ type: string }>;
serialize(
result: MathJSLabExecutionResult,
): Promise<{ status: string; outputs: Array<{ type: string; markup?: string; text?: string; request?: { data: Array<{ y: number[] }> } }>; error?: string }>;
};
}
}
test.beforeEach(async ({ page }) => {
const initializationErrors: string[] = [];
page.on('pageerror', (error) => initializationErrors.push(error.message));
await page.goto('/test/browser/fixtures/mathjslab-worker.html');
expect(initializationErrors, 'Worker fixture module initialization').toEqual([]);
await expect(page.locator('markdown-document')).toHaveAttribute('state', 'ready', { timeout: 20_000 });
});
test('renders retained calls and loops through Markdown statement slicing and shared document workspace', async ({ page }) => {
await page.evaluate(() => {
const harness = window.mathJSLabWorkerTest;
harness.effects.length = 0;
harness.viewer.markdown = `
\`\`\`mathjslab
global count; count=0
\`\`\`
\`\`\`mathjslab
function y=step(v)
global count; count+=1;
pause(0.01); y=v+count;
end
value=0;
for i=1:2
value+=step(i);
end
value
\`\`\`
\`\`\`mathjslab
count
\`\`\``;
});
const viewer = page.locator('markdown-document');
await expect(viewer.locator('.mathjslab-result')).toHaveCount(7, { timeout: 20_000 });
await expect(viewer).toHaveAttribute('state', 'ready', { timeout: 20_000 });
await expect(viewer.locator('.mathjslab-result[data-state="bad"]')).toHaveCount(0);
await expect(viewer.locator('.mathjslab-result').nth(5)).toContainText('6');
await expect(viewer.locator('.mathjslab-result').last()).toContainText('2');
expect(await page.evaluate(() => window.mathJSLabWorkerTest.effects)).toEqual([
{ type: 'delay', milliseconds: 10 },
{ type: 'delay', milliseconds: 10 },
]);
});
test('cleans active contexts during a suspended function and cancels its pending delay', async ({ page }) => {
const result = await page.evaluate(async () => {
const harness = window.mathJSLabWorkerTest;
const session = await harness.executor.createSession({});
const controller = new AbortController();
const nextEffect = harness.nextEffect();
const pending = session.execute('function y=f(), plot([1,2]); pause(60); plot([3,4]); y=9; end; x=f()', { blockIndex: 0, statementIndex: 0, signal: controller.signal });
const effect = await nextEffect;
const duringCall = await harness.probe();
controller.abort('cancel retained call');
const cancelled = await harness.serialize(await pending);
const recovery = await harness.serialize(await session.execute("exist('x')", { blockIndex: 0, statementIndex: 0 }));
const afterRecovery = await harness.probe();
await session.dispose?.();
return { effect, duringCall, cancelled, recovery, afterRecovery };
});
expect(result.effect.type).toBe('delay');
expect(result.duringCall).toMatchObject({ interpreter: false, plots: false });
expect(result.cancelled).toMatchObject({ status: 'cancelled', outputs: [] });
expect(result.recovery.outputs[0]?.markup).toContain('0');
expect(result.afterRecovery).toMatchObject({ interpreter: false, plots: false });
});
test('retains control flow, argument order, multiple outputs and chained writes through real Worker waits', async ({ page }) => {
const result = await page.evaluate(async () => {
const { executor, effects, serialize } = window.mathJSLabWorkerTest;
const session = await executor.createSession({});
const context = { blockIndex: 0, statementIndex: 0 };
const start = effects.length;
const execution = await session.execute(
`
global trace; trace=0;
function y=mark(v)
global trace; trace=trace*10+v;
pause(v/1000);
y=v;
end
function [a,b]=pair(x,y)
a=x+y; pause(0); b=x*y;
end
before=7;
for i=1:2
if i==1
[a,b]=pair(mark(1),mark(2));
else
h=@(x)mark(x); a+=h(3);
end
end
C={[10,20]}; C{mark(1)}(mark(2))=mark(4);
s.a=5;
function y=field(), pause(0); y='a'; end
s.(field())+=mark(5);
after=before+a+b+C{1}(end)+s.a;
trace`,
context,
);
const workspace = await session.execute('after', context);
await session.dispose?.();
return { execution: await serialize(execution), workspace: await serialize(workspace), effects: effects.slice(start) };
});
expect(result.execution.status).toBe('success');
expect(result.execution.outputs[0]?.markup).toContain('1231245');
expect(result.workspace.outputs[0]?.markup).toContain('29');
expect(result.effects.map((effect) => effect.type)).toEqual(Array(9).fill('delay'));
expect(result.effects.map((effect) => effect.milliseconds)).toEqual([1, 2, 0, 3, 1, 2, 4, 0, 5]);
});
test('captures plots once before and after waits and binds external functions to their session', async ({ page }) => {
const result = await page.evaluate(async () => {
const { executor, serialize } = window.mathJSLabWorkerTest;
const session = await executor.createSession({});
const context = { blockIndex: 0, statementIndex: 0 };
const execution = await serialize(
await session.execute(
`
n=3; plot([1,2]);
function y=f(), pause(0); y=8; end
x=f(); plot([x,9]);
summation(k,1,n,k)+productory(k,1,n,k)`,
context,
),
);
const next = await serialize(await session.execute('x+n', context));
const sampled = await serialize(await session.execute('plot2d(t,t,0,1)', context));
const failure = await serialize(await session.execute('summation(k,1,n,missing)', context));
const recovered = await serialize(await session.execute('summation(k,1,n,k)', context));
const afterSteps = await window.mathJSLabWorkerTest.probe();
await session.dispose?.();
return { execution, next, sampled, failure, recovered, afterSteps };
});
expect(result.execution.status).toBe('success');
expect(result.execution.outputs.filter((output) => output.type === 'plot').map((output) => output.request?.data[0]?.y)).toEqual([
[1, 2],
[8, 9],
]);
expect(result.execution.outputs[0]?.markup).toContain('12');
expect(result.next.outputs).toHaveLength(1);
expect(result.next.outputs[0]?.markup).toContain('11');
expect(result.sampled.status).toBe('success');
expect(result.sampled.outputs.filter((output) => output.type === 'plot')[0]?.request?.data[0]?.y).toHaveLength(100);
expect(result.failure.status).toBe('error');
expect(result.recovered.outputs[0]?.markup).toContain('6');
expect(result.afterSteps).toMatchObject({ interpreter: false, plots: false });
});
test('resumes repeated relative loads in a loop and retains caller instructions', async ({ page }) => {
const result = await page.evaluate(async () => {
const { executor, serialize, effects } = window.mathJSLabWorkerTest;
const context = { sourceUrl: new URL('/test/browser/fixtures/worker-lesson.md', location.href), blockIndex: 0, statementIndex: 0 };
const session = await executor.createSession(context);
const start = effects.length;
const executed = await serialize(await session.execute("x=0; for i=1:2; x+=1; load('./mathjslab-loaded.m'); x+=workerLoaded; end; x", context));
await session.dispose?.();
return { executed, effects: effects.slice(start) };
});
expect(result.executed.status).toBe('success');
expect(result.executed.outputs[0]?.markup).toContain('148');
expect(result.executed.outputs.filter((output) => output.type === 'text')).toHaveLength(2);
expect(result.effects).toMatchObject([
{ type: 'read-text', reference: './mathjslab-loaded.m' },
{ type: 'read-text', reference: './mathjslab-loaded.m' },
]);
});
test('keeps independent numeric settings and workspaces while another session waits', async ({ page }) => {
const result = await page.evaluate(async () => {
const { executor, serialize } = window.mathJSLabWorkerTest;
const first = await executor.createSession({});
const second = await executor.createSession({});
const context = { blockIndex: 0, statementIndex: 0 };
await first.execute("configure('precision',20); x=7", context);
await second.execute("configure('precision',40); x=11", context);
const waiting = first.execute("function y=f(), pause(0.05); y=getconfig('precision'); end; observed=f(); x+=1; observed", context);
const other = await serialize(await second.execute("x+=1; getconfig('precision')", context));
const resumed = await serialize(await waiting);
const x1 = await serialize(await first.execute('x', context));
const x2 = await serialize(await second.execute('x', context));
await first.dispose?.();
const surviving = await serialize(await second.execute('x+1', context));
await second.dispose?.();
return { resumed, other, x1, x2, surviving };
});
expect(result.resumed.status).toBe('success');
expect(result.resumed.outputs[0]?.markup).toContain('20');
expect(result.other.outputs[0]?.markup).toContain('40');
expect(result.x1.outputs[0]?.markup).toContain('8');
expect(result.x2.outputs[0]?.markup).toContain('12');
expect(result.surviving.outputs[0]?.markup).toContain('13');
});
test('injects host failures into catch and cleanup, preserving prior workspace effects', async ({ page }) => {
const result = await page.evaluate(async () => {
const { executor, serialize, effects } = window.mathJSLabWorkerTest;
const context = { blockIndex: 0, statementIndex: 0, resourcePolicy: { allowNetwork: false }, sourceUrl: new URL('/lesson.md', location.href) };
const session = await executor.createSession(context);
const start = effects.length;
const caught = await serialize(await session.execute("x=1; try; load('./missing.m'); x=99; catch; x+=2; end; pause(0); x+=4; x", context));
const failure = await serialize(await session.execute("unwind_protect; x+=8; load('./missing.m'); x=99; unwind_protect_cleanup; x+=16; end_unwind_protect", context));
const recovery = await serialize(await session.execute('x', context));
await session.dispose?.();
return { caught, failure, recovery, effects: effects.slice(start) };
});
expect(result.caught.status).toBe('success');
expect(result.caught.outputs[0]?.markup).toContain('7');
expect(result.failure.status).toBe('error');
expect(result.failure.error).toContain('Network access is not allowed');
expect(result.recovery.outputs[0]?.markup).toContain('31');
expect(result.effects.map((effect) => effect.type)).toEqual(['read-text', 'delay', 'read-text']);
});
for (const interruption of ['abort', 'timeout', 'dispose'] as const) {
test(`${interruption} aborts the outstanding host read and ignores late results`, async ({ page }) => {
const result = await page.evaluate(async (interruption) => {
const harness = window.mathJSLabWorkerTest;
const controller = new AbortController();
let release!: (text: string) => void;
let started!: () => void;
const ready = new Promise((resolve) => {
started = resolve;
});
let hostSignal: AbortSignal | undefined;
const context = {
blockIndex: 0,
statementIndex: 0,
sourceUrl: new URL('/lesson.md', location.href),
fetchResource: async (_url: URL, init: RequestInit) => {
hostSignal = init.signal ?? undefined;
started();
return new Promise((resolve) => {
release = (text) => resolve(new Response(text));
});
},
};
const session = await harness.executor.createSession(context);
await session.execute('x=7', context);
const count = harness.workerCount;
const pending = Promise.resolve(
session.execute("x+=1; load('held.m'); x=99", {
...context,
signal: controller.signal,
...(interruption === 'timeout' ? { executionPolicy: { mathjslab: { timeoutMs: 2000 } } } : {}),
}),
).then(
(result) => ({ status: result.status }),
(error: Error & { code?: string }) => ({ status: error.name, code: error.code }),
);
await ready;
const duringWait = await harness.probe();
if (interruption === 'abort') controller.abort('test abort');
if (interruption === 'dispose') {
await session.dispose?.();
await session.dispose?.();
}
const interrupted = await pending;
const aborted = hostSignal?.aborted;
release('x=1234');
let recovery;
if (interruption === 'dispose') {
try {
await session.execute('1', context);
} catch (error) {
recovery = String(error);
}
} else recovery = await harness.serialize(await session.execute("exist('x')", context));
const fresh = await harness.executor.createSession({});
const independent = await harness.serialize(await fresh.execute('2+3', { blockIndex: 0, statementIndex: 0 }));
await fresh.dispose?.();
await session.dispose?.();
return { interrupted, aborted, recovery, independent, duringWait, recreated: harness.workerCount > count };
}, interruption);
expect(result.aborted).toBe(true);
expect(result.duringWait).toMatchObject({ interpreter: false, plots: false });
expect(result.interrupted).toMatchObject(interruption === 'timeout' ? { status: 'TimeoutError', code: 'MATHJSLAB_TIMEOUT' } : { status: 'cancelled' });
if (interruption === 'dispose') expect(result.recovery).toContain('session is disposed');
else {
expect(result.recreated).toBe(true);
expect(result.recovery).toMatchObject({ status: 'success', outputs: [{ type: 'mathml', markup: expect.stringContaining('0') }] });
}
expect(result.independent.outputs[0]?.markup).toContain('5');
});
}
test('preserves ordinary variable and user-function dispatch for effect names', async ({ page }) => {
const result = await page.evaluate(async () => {
const { executor, serialize, effects } = window.mathJSLabWorkerTest;
const session = await executor.createSession({});
const context = { blockIndex: 0, statementIndex: 0 };
const start = effects.length;
const variable = await serialize(await session.execute('pause=[4,9]; pause(2)', context));
const callable = await serialize(await session.execute('clear pause; function y=pause(x), y=x+10; end; pause(2)', context));
await session.dispose?.();
return { variable, callable, effects: effects.slice(start) };
});
expect(result.variable.outputs[0]?.markup).toContain('9');
expect(result.callable.outputs[0]?.markup).toContain('12');
expect(result.effects).toEqual([]);
});
test('retains literal elements used directly by plots and nested cells', async ({ page }) => {
const result = await page.evaluate(async () => {
const harness = window.mathJSLabWorkerTest;
const session = await harness.executor.createSession({});
harness.effects.length = 0;
try {
const execution = await harness.serialize(
await session.execute(
'function y=mark(v), global trace; trace=trace*10+v; pause(v/1000); y=v; end; global trace; trace=0; plot([mark(1),mark(2);mark(3),mark(4)]); c={mark(5),[mark(6),mark(7)]}; trace',
{ blockIndex: 0, statementIndex: 0 },
),
);
const workspace = await harness.serialize(await session.execute('sum(c{2})+c{1}', { blockIndex: 1, statementIndex: 0 }));
return { execution, workspace, effects: [...harness.effects], contexts: await harness.probe() };
} finally {
await session.dispose?.();
}
});
expect(result.execution.status).toBe('success');
expect(result.execution.outputs.find((output) => output.type === 'mathml')?.markup).toContain('1234567');
const plot = result.execution.outputs.find((output) => output.type === 'plot');
expect(plot?.request?.data.map((series) => series.y)).toEqual([
[1, 3],
[2, 4],
]);
expect(result.workspace.outputs[0]?.markup).toContain('18');
expect(result.effects).toEqual([1, 2, 3, 4, 5, 6, 7].map((milliseconds) => ({ type: 'delay', milliseconds })));
expect(result.contexts).toMatchObject({ interpreter: false, plots: false });
});
test('cancels a second literal element and recovers with a clean Worker', async ({ page }) => {
const result = await page.evaluate(async () => {
const harness = window.mathJSLabWorkerTest;
const session = await harness.executor.createSession({});
const controller = new AbortController();
harness.effects.length = 0;
try {
const first = harness.nextEffect();
const pending = session.execute('function y=g(a), if a==2, pause(60); else, pause(0.01); end; y=a; end; x=0; a=99; a=[g(++x),g(++x),g(++x)]; plot(a)', {
blockIndex: 0,
statementIndex: 0,
signal: controller.signal,
});
await first;
await harness.nextEffect();
const contexts = await harness.probe();
controller.abort('cancel literal element');
const cancelled = await harness.serialize(await pending);
const effects = [...harness.effects];
const recovery = await harness.serialize(await session.execute('exist("x","var")', { blockIndex: 1, statementIndex: 0 }));
return { cancelled, recovery, effects, contexts, recoveredContexts: await harness.probe() };
} finally {
await session.dispose?.();
}
});
expect(result.cancelled).toMatchObject({ status: 'cancelled', outputs: [] });
expect(result.effects).toEqual([
{ type: 'delay', milliseconds: 10 },
{ type: 'delay', milliseconds: 60000 },
]);
expect(result.recovery.outputs[0]?.markup).toContain('0');
expect(result.contexts).toMatchObject({ interpreter: false, plots: false });
expect(result.recoveredContexts).toMatchObject({ interpreter: false, plots: false });
});
test('retains completed element effects when concatenation fails', async ({ page }) => {
const result = await page.evaluate(async () => {
const harness = window.mathJSLabWorkerTest;
const session = await harness.executor.createSession({});
harness.effects.length = 0;
try {
const failed = await harness.serialize(
await session.execute('function y=g(a), global count; count+=1; pause(a/1000); y=a; end; global count; count=0; a=99; a=[g(1);g(2),g(3)]; plot(a)', {
blockIndex: 0,
statementIndex: 0,
}),
);
const workspace = await harness.serialize(await session.execute('global count; a+count', { blockIndex: 1, statementIndex: 0 }));
return { failed, workspace, effects: [...harness.effects], contexts: await harness.probe() };
} finally {
await session.dispose?.();
}
});
expect(result.failed.status).toBe('error');
expect(result.failed.outputs).toEqual([]);
expect(result.workspace.outputs[0]?.markup).toContain('102');
expect(result.effects).toEqual([1, 2, 3].map((milliseconds) => ({ type: 'delay', milliseconds })));
expect(result.contexts).toMatchObject({ interpreter: false, plots: false });
});
test('propagates a host failure from a literal element without replaying completed work', async ({ page }) => {
const result = await page.evaluate(async () => {
const harness = window.mathJSLabWorkerTest;
const session = await harness.executor.createSession({});
const original = window.setTimeout;
window.setTimeout = new Proxy(original, {
apply(target, receiver, args) {
if (args[1] === 77) throw new Error('literal host failure');
return Reflect.apply(target, receiver, args);
},
});
harness.effects.length = 0;
try {
const execution = await harness.serialize(
await session.execute('function y=g(a), global count; count+=1; pause(a/1000); y=a; end; global count; count=0; a=99; try, a=[g(1),g(77),g(3)]; catch, end; a+count', {
blockIndex: 0,
statementIndex: 0,
}),
);
return { execution, effects: [...harness.effects], contexts: await harness.probe() };
} finally {
window.setTimeout = original;
await session.dispose?.();
}
});
expect(result.execution.status).toBe('success');
expect(result.execution.outputs[0]?.markup).toContain('101');
expect(result.effects).toEqual([
{ type: 'delay', milliseconds: 1 },
{ type: 'delay', milliseconds: 77 },
]);
expect(result.contexts).toMatchObject({ interpreter: false, plots: false });
});
test('keeps numeric session configuration through a literal element wait', async ({ page }) => {
const result = await page.evaluate(async () => {
const harness = window.mathJSLabWorkerTest;
const first = await harness.executor.createSession({});
const second = await harness.executor.createSession({});
const context = { blockIndex: 0, statementIndex: 0 };
try {
await first.execute('configure("precision",20)', context);
const effect = harness.nextEffect();
const pending = first.execute('function y=g(), pause(0.1); y=getconfig("precision"); end; c={g()}; c{1}', context);
await effect;
await second.execute('configure("precision",40)', context);
const resumed = await harness.serialize(await pending);
const other = await harness.serialize(await second.execute('getconfig("precision")', context));
return { resumed, other };
} finally {
await first.dispose?.();
await second.dispose?.();
}
});
expect(result.resumed.status).toBe('success');
expect(result.resumed.outputs[0]?.markup).toContain('20');
expect(result.other.outputs[0]?.markup).toContain('40');
});
test('uses resumable plot literals across Markdown blocks', async ({ page }) => {
await page.evaluate(() => {
const harness = window.mathJSLabWorkerTest;
harness.effects.length = 0;
harness.viewer.markdown =
'\n```mathjslab\nglobal count; count=0\nfunction y=sample(v)\nglobal count; count+=1; pause(0.001); y=v;\nend\n```\n\n```mathjslab\nplot([sample(1),sample(2)])\n```\n\n```mathjslab\ncount\n```';
});
const viewer = page.locator('markdown-document');
await expect(viewer.locator('.plot-container')).toHaveCount(1, { timeout: 20_000 });
await expect(viewer).toHaveAttribute('state', 'ready', { timeout: 20_000 });
await expect(viewer.locator('.mathjslab-result[data-state="bad"]')).toHaveCount(0);
await expect(viewer.locator('.mathjslab-result').last()).toContainText('2');
const request = JSON.parse((await viewer.locator('.plot-container').getAttribute('data-request')) ?? '{}');
expect(request.data.map((series: { y: number[] }) => series.y)).toEqual([[1, 2]]);
expect(await page.evaluate(() => window.mathJSLabWorkerTest.effects)).toEqual([
{ type: 'delay', milliseconds: 1 },
{ type: 'delay', milliseconds: 1 },
]);
});