Accumulated uncommitted work: GHOST Web backend, WebGL/GPU web layer, web workflow engine and protocol, tooling, docs and task records. Large generated evidence, user-testing archives, build snapshots and imported test assets remain untracked. Co-Authored-By: Claude Code <noreply@anthropic.com>
154 lines
23 KiB
TypeScript
154 lines
23 KiB
TypeScript
import { expect, test, type Page } from '@playwright/test';
|
|
import path from 'node:path';
|
|
import fs from 'node:fs';
|
|
import { execFileSync } from 'node:child_process';
|
|
import { createHash } from 'node:crypto';
|
|
import { readBackendMain } from '../fixtures/backend-main-readback';
|
|
type Handle = { id: string; token: string };
|
|
const asset = path.resolve(import.meta.dirname, '../../../blender-5.2.0/tests/files/io_tests/blend_geometry/all_quads.blend');
|
|
const headers = (handle: Handle, action: string, origin: string) => ({ 'X-Blender-Workflow': '1', 'X-Blender-Session-Action': action, 'X-Blender-Session-Token': handle.token, Origin: origin });
|
|
async function checkpointIdentity(page: Page) {
|
|
return page.evaluate(() => new Promise<{ id: string; savedAt: number }>((resolve, reject) => {
|
|
const slot = sessionStorage.getItem('blender-desktop-slot'), open = indexedDB.open('blender-desktop-workspaces', 1);
|
|
open.onerror = () => reject(open.error); open.onupgradeneeded = () => { open.transaction?.abort(); reject(new Error('Missing desktop checkpoint database')); };
|
|
open.onsuccess = () => { const db = open.result, request = db.transaction('recovery', 'readonly').objectStore('recovery').getAll(); request.onerror = () => { db.close(); reject(request.error); }; request.onsuccess = () => { const latest = request.result.filter((item: any) => item.slot === slot).sort((a: any, b: any) => b.savedAt - a.savedAt)[0]; db.close(); if (!latest) reject(new Error('Missing owned checkpoint')); else resolve({ id: latest.id, savedAt: latest.savedAt }); }; };
|
|
}));
|
|
}
|
|
async function startDesktop(page: Page, handles: Handle[], action: () => Promise<unknown> = () => page.goto(`/?ui=desktop&desktopSlot=${crypto.randomUUID()}`)) {
|
|
const starting = page.waitForResponse(response => new URL(response.url()).pathname === '/api/blender-session' && response.request().headers()['x-blender-session-action'] === 'start');
|
|
await action();
|
|
const response = await starting, body = await response.json(); expect(response.status(), body.error).toBe(202);
|
|
const handle = { id: body.id, token: body.token }; expect(typeof handle.id).toBe('string'); expect(typeof handle.token).toBe('string'); handles.push(handle);
|
|
await expect(page.locator('main.desktop-workspace')).toHaveAttribute('data-session-status', 'READY', { timeout: 120000 });
|
|
await expect(page.locator('main.desktop-workspace img').first()).toBeVisible({ timeout: 60000 }); return handle;
|
|
}
|
|
async function desktopKey(page: Page, key: string, code: string) {
|
|
const received = page.waitForResponse(response => response.request().headers()['x-blender-session-action'] === 'events' && response.request().postDataJSON()?.events?.some((event: any) => event.type === 'key' && event.code === code && event.down === false));
|
|
await page.getByLabel('Blender 键盘输入', { exact: true }).press(key);
|
|
expect((await received).status()).toBe(200);
|
|
}
|
|
test('Backend original asset stays owned across independent sessions', async ({ page, browser, request }, info) => {
|
|
test.setTimeout(300000); const origin = String(info.project.use.baseURL), handles: Handle[] = [], peerContext = await browser.newContext({ baseURL: origin });
|
|
try {
|
|
expect(process.env.WEB_BLENDER_PROFILE_ROOT).toBeTruthy(); expect(path.resolve(process.env.WEB_BLENDER_PROFILE_ROOT!).startsWith(path.resolve(import.meta.dirname, '../../../tests/golden') + path.sep)).toBe(true);
|
|
const source = "import bpy,json\nprint('WBV2_BASELINE='+json.dumps({'version':list(bpy.app.version),'objects':[{'name':o.name,'location':list(o.location)} for o in sorted(bpy.data.objects,key=lambda o:o.name)]}))";
|
|
const output = execFileSync(process.env.BLENDER_BIN!, ['--background', '--factory-startup', '--disable-autoexec', asset, '--python-exit-code', '1', '--python-expr', source], { encoding: 'utf8', timeout: 60000 });
|
|
const expected = JSON.parse(output.split('\n').find(line => line.startsWith('WBV2_BASELINE='))!.slice('WBV2_BASELINE='.length)); expect(expected.version).toEqual([5, 2, 0]);
|
|
const own = await startDesktop(page, handles);
|
|
page.on('dialog', async dialog => { expect(dialog.message()).toMatch(/打开将替换|重新连接/); await dialog.accept(); });
|
|
await page.locator('input[type="file"]').first().setInputFiles(asset);
|
|
await page.getByRole('button', { name: /^打开 .*all_quads\.blend$/ }).click();
|
|
await expect(page.getByRole('complementary', { name: '会话文件' })).toBeHidden();
|
|
const downloading = page.waitForEvent('download', { timeout: 60000 }); await page.getByRole('button', { name: '下载工程及资源', exact: true }).click();
|
|
const downloaded = await downloading, target = info.outputPath('backend-owned-project.zip'); expect(downloaded.suggestedFilename()).toMatch(/\.zip$/i);
|
|
await downloaded.saveAs(target); expect(fs.readFileSync(target).subarray(0, 2).toString()).toBe('PK');
|
|
const before = await readBackendMain(request, origin, own); expect(before.objects).toEqual(expected.objects);
|
|
const peer = await peerContext.newPage(), other = await startDesktop(peer, handles);
|
|
expect(own.id !== other.id && own.token !== other.token).toBe(true);
|
|
const denied = await peerContext.request.get(`${origin}/api/blender-session?id=${encodeURIComponent(own.id)}`, { headers: headers(other, 'files', origin) }); expect(denied.status()).toBe(404);
|
|
const ownFiles = await request.get(`${origin}/api/blender-session?id=${encodeURIComponent(own.id)}`, { headers: headers(own, 'files', origin) });
|
|
const peerFiles = await peerContext.request.get(`${origin}/api/blender-session?id=${encodeURIComponent(other.id)}`, { headers: headers(other, 'files', origin) });
|
|
expect(ownFiles.ok()).toBe(true); expect(peerFiles.ok()).toBe(true); const owned = (await ownFiles.json()).files, isolated = (await peerFiles.json()).files;
|
|
expect(owned.some((file: { path: string }) => file.path.endsWith('/all_quads.blend'))).toBe(true); expect(isolated.some((file: { path: string }) => file.path.endsWith('/all_quads.blend'))).toBe(false);
|
|
const after = await readBackendMain(request, origin, own); expect(after.objects).toEqual(before.objects);
|
|
fs.writeFileSync(info.outputPath('backend-owned-main.json'), JSON.stringify({ expected, before, after, crossSessionStatus: denied.status(), peerFileCount: isolated.length, profileRoot: process.env.WEB_BLENDER_PROFILE_ROOT }, null, 2));
|
|
} finally {
|
|
for (const handle of handles) { const closed = await request.post(`${origin}/api/blender-session`, { headers: headers(handle, 'close', origin), data: handle }); expect([200, 404]).toContain(closed.status()); }
|
|
await peerContext.close();
|
|
}
|
|
});
|
|
for (const mode of ['quit', 'image-quit', 'abrupt', 'evicted', 'storage-failure']) test(mode === 'storage-failure' ? 'Backend checkpoint write failure preserves Main and retries recovery' : mode === 'evicted' ? 'Backend evicted autosave preserves only the browser checkpoint fallback' : mode === 'abrupt' ? 'Backend abrupt native loss recovers only the last autosave' : mode === 'image-quit' ? 'Backend external image quit recovers native Main and resources' : 'Backend native quit recovers unsaved Main in its owning slot', async ({ page, browser, request }, info) => {
|
|
const external = mode !== 'quit', evicted = mode === 'evicted', abrupt = mode === 'abrupt' || evicted; let autosave = '';
|
|
const caseAsset = external ? path.resolve(path.dirname(asset), '../blend_autosave/autosave_image_test.blend') : asset;
|
|
test.setTimeout(300000); const origin = String(info.project.use.baseURL), handles: Handle[] = [], peerContext = await browser.newContext({ baseURL: origin });
|
|
const evidence: Record<string, unknown> = {}, record = (key: string, value: unknown) => { evidence[key] = value; fs.writeFileSync(info.outputPath('backend-quit-recovery.json'), JSON.stringify(evidence, null, 2)); };
|
|
const desktopSource = "import bpy,json\nprint('WBV2_BASELINE='+json.dumps({'objects':[{'name':o.name,'location':list(o.location)} for o in sorted(bpy.data.objects,key=lambda o:o.name)],'images':[{'name':i.name,'packed':bool(i.packed_file)} for i in sorted(bpy.data.images,key=lambda i:i.name) if i.source=='FILE']}))";
|
|
const desktopOutput = execFileSync(process.env.BLENDER_BIN!, ['--background', '--factory-startup', '--disable-autoexec', caseAsset, '--python-exit-code', '1', '--python-expr', desktopSource], { encoding: 'utf8', timeout: 60000 });
|
|
const desktop = JSON.parse(desktopOutput.split('\n').find(line => line.startsWith('WBV2_BASELINE='))!.slice('WBV2_BASELINE='.length)); record('desktop', desktop);
|
|
const assertImages = (main: Awaited<ReturnType<typeof readBackendMain>>) => {
|
|
expect(main.images.map(({ name, packed }) => ({ name, packed }))).toEqual(desktop.images);
|
|
if(external){const image = main.images.find(image => image.name === 'ExternalImage')!; expect(image.exists).toBe(true); expect(image.absolute.startsWith(main.workspace + path.sep)).toBe(true); expect(fs.readFileSync(image.absolute).equals(fs.readFileSync(path.join(path.dirname(caseAsset), 'ExternalImage.png')))).toBe(true);}
|
|
};
|
|
try {
|
|
expect(process.env.WEB_BLENDER_PROFILE_ROOT).toBeTruthy(); expect(path.resolve(process.env.WEB_BLENDER_PROFILE_ROOT!).startsWith(path.resolve(import.meta.dirname, '../../../tests/golden') + path.sep)).toBe(true);
|
|
page.on('dialog', async dialog => { expect(dialog.message()).toMatch(/打开将替换|重新连接/); await dialog.accept(); });
|
|
const own = await startDesktop(page, handles);
|
|
await page.locator('input[type="file"]').first().setInputFiles(external ? [caseAsset, path.join(path.dirname(caseAsset), 'ExternalImage.png')] : caseAsset);
|
|
await page.getByRole('button', { name: new RegExp(`^打开 .*${path.basename(caseAsset).replace('.', '\\.')}$`) }).click(); await expect(page.getByRole('complementary', { name: '会话文件' })).toBeHidden();
|
|
const downloading = page.waitForEvent('download', { timeout: 60000 }); await page.getByRole('button', { name: '下载工程及资源', exact: true }).click();
|
|
await (await downloading).saveAs(info.outputPath('backend-before-unsaved.zip'));
|
|
const initial = await readBackendMain(request, origin, own); record('initial', initial); expect(initial.objects).toEqual(desktop.objects); assertImages(initial);
|
|
const relativeMain = path.relative(initial.workspace, initial.filepath); expect(path.isAbsolute(relativeMain) || relativeMain.startsWith('..')).toBe(false);
|
|
const checkpoint = await checkpointIdentity(page); record('checkpointBeforeEdit', checkpoint);
|
|
const contender = await peerContext.newPage(), rejected = contender.waitForResponse(response => response.request().headers()['x-blender-session-action'] === 'start');
|
|
await contender.goto(page.url()); const refusal = await rejected; expect(refusal.status()).toBe(409); expect((await refusal.json()).error).toContain('SESSION_RECOVERY_OWNER_ACTIVE'); await contender.close(); record('duplicateOwnerRefused', true);
|
|
const peer = await peerContext.newPage(), other = await startDesktop(peer, handles), peerBefore = await readBackendMain(peerContext.request, origin, other);
|
|
const image = page.locator('main.desktop-workspace img').first(), bounds = await image.boundingBox(); expect(bounds).not.toBeNull();
|
|
const selection = page.waitForResponse(response => response.request().headers()['x-blender-session-action'] === 'events' && response.request().postDataJSON()?.events?.some((event: any) => event.type === 'button' && event.down === false));
|
|
await page.mouse.click(bounds!.x + bounds!.width * 0.41, bounds!.y + bounds!.height * 0.45);
|
|
expect((await selection).ok()).toBe(true); const selected = await readBackendMain(request, origin, own); expect(selected.activeObject).toBeTruthy(); record('selected', selected);
|
|
const marker = `goalBookkeeper_Aa${crypto.randomUUID().replaceAll('-', '')}`; record('marker', marker);
|
|
const expectedObjects = initial.objects.map(object => ({ ...object, name: object.name === selected.activeObject ? marker : object.name })).sort((a, b) => a.name < b.name ? -1 : 1);
|
|
await desktopKey(page, 'F2', 'F2'); await page.getByLabel('Blender 键盘输入', { exact: true }).press('Control+a'); await page.keyboard.type(marker); await desktopKey(page, 'Enter', 'Enter');
|
|
await expect.poll(async () => (await readBackendMain(request, origin, own)).objects, { timeout: 15000 }).toEqual(expectedObjects);
|
|
const edited = await readBackendMain(request, origin, own); expect(edited.objects).toEqual(expectedObjects); record('edited', edited);
|
|
await desktopKey(page, 'Control+z', 'KeyZ'); await expect.poll(async () => (await readBackendMain(request, origin, own)).objects, { timeout: 15000 }).toEqual(initial.objects);
|
|
assertImages(await readBackendMain(request, origin, own));
|
|
await desktopKey(page, 'Control+Shift+z', 'KeyZ'); await expect.poll(async () => (await readBackendMain(request, origin, own)).objects, { timeout: 15000 }).toEqual(edited.objects);
|
|
assertImages(await readBackendMain(request, origin, own)); record('undoRedoPreserved', true);
|
|
if(mode === 'storage-failure'){
|
|
const beforeFault = await readBackendMain(request, origin, own), blocked = path.join(beforeFault.workspace, `result-${String(beforeFault.sequence + 1).padStart(8, '0')}.blend`); fs.mkdirSync(blocked);
|
|
try{const failed = page.waitForResponse(async response => response.request().headers()['x-blender-session-action'] === 'read' && (await response.json()).command?.status === 'FAILED', { timeout: 30000 }); await page.getByRole('button', { name: '下载工程及资源', exact: true }).click(); const failure = (await (await failed).json()).command; expect(failure.sequence).toBe(beforeFault.sequence + 1); expect(JSON.stringify(failure)).toContain(path.basename(blocked)); record('checkpointWriteFailed', { blocked, failure });}
|
|
finally{fs.rmdirSync(blocked);}
|
|
await expect(page.getByRole('button', { name: '下载工程及资源', exact: true })).toBeEnabled(); const afterFault = await readBackendMain(request, origin, own); expect(afterFault.objects).toEqual(beforeFault.objects); expect(afterFault.images).toEqual(beforeFault.images); expect(afterFault.filepath).toBe(beforeFault.filepath); expect(await checkpointIdentity(page)).toEqual(checkpoint); record('failedWritePreserved', afterFault);
|
|
const retry = page.waitForEvent('download', { timeout: 60000 }); await page.getByRole('button', { name: '下载工程及资源', exact: true }).click(); await (await retry).saveAs(info.outputPath('checkpoint-after-write-retry.zip')); const saved = await checkpointIdentity(page); expect(saved.id).not.toBe(checkpoint.id);
|
|
expect(fs.readFileSync(afterFault.filepath).equals(fs.readFileSync(caseAsset))).toBe(true); expect((await request.post(`${origin}/api/blender-session`, { headers: headers(own, 'close', origin), data: own })).status()).toBe(200);
|
|
await expect(page.locator('main.desktop-workspace')).toHaveAttribute('data-session-status', 'DISCONNECTED', { timeout: 30000 }); const restarted = await startDesktop(page, handles, () => page.getByRole('button', { name: '重新连接', exact: true }).click());
|
|
const restored = await readBackendMain(request, origin, restarted); expect(restored.objects).toEqual(edited.objects); assertImages(restored); expect((await readBackendMain(peerContext.request, origin, other)).objects).toEqual(peerBefore.objects); record('checkpointRetryReopened', { saved, restored, ordinaryUnchanged: true, peerUnchanged: true }); return;
|
|
}
|
|
expect(await checkpointIdentity(page)).toEqual(checkpoint);
|
|
await desktopKey(page, 'Control+q', 'KeyQ'); await desktopKey(page, 'Escape', 'Escape');
|
|
const cancelled = await readBackendMain(request, origin, own); expect(cancelled.objects).toEqual(edited.objects); record('quitCancelled', cancelled);
|
|
const filesResponse = await request.get(`${origin}/api/blender-session?id=${encodeURIComponent(own.id)}`, { headers: headers(own, 'files', origin) }); expect(filesResponse.ok()).toBe(true); const files = await filesResponse.json(); expect(files.status.dirty).toBe(true);
|
|
const ordinaryPath = files.files.find((file: { path: string }) => file.path.endsWith('/' + path.basename(caseAsset)))?.path; expect(typeof ordinaryPath).toBe('string');
|
|
const ordinary = await request.get(`${origin}/api/blender-session?id=${encodeURIComponent(own.id)}&file=${encodeURIComponent(ordinaryPath)}`, { headers: headers(own, 'download', origin) }); expect(ordinary.ok()).toBe(true);
|
|
const ordinaryBytes = await ordinary.body(); expect(ordinaryBytes.equals(fs.readFileSync(caseAsset))).toBe(true); fs.writeFileSync(info.outputPath('ordinary-before-quit.blend'), ordinaryBytes);
|
|
expect(await checkpointIdentity(page)).toEqual(checkpoint); record('beforeQuit', { main: edited, checkpoint, dirty: files.status.dirty, ordinaryPath });
|
|
if(abrupt){
|
|
await page.mouse.move(bounds!.x + bounds!.width * (external ? 0.62 : 0.41), bounds!.y + bounds!.height * 0.45);
|
|
await desktopKey(page, 'Shift+F4', 'F4'); await page.keyboard.type(`import os; assert not os.path.exists(${JSON.stringify(peerBefore.workspace)}); bpy.ops.wm.save_auto_save()`); await desktopKey(page, 'Enter', 'Enter');
|
|
await expect.poll(() => fs.readdirSync(initial.workspace).filter(name => name.endsWith('_autosave.blend')), { timeout: 15000 }).toHaveLength(1); autosave = path.join(initial.workspace, fs.readdirSync(initial.workspace).find(name => name.endsWith('_autosave.blend'))!);
|
|
record('lastAutosave', { path: autosave, sha256: createHash('sha256').update(fs.readFileSync(autosave)).digest('hex'), main: edited });
|
|
await desktopKey(page, 'Shift+F5', 'F5');
|
|
await desktopKey(page, 'F2', 'F2'); await page.getByLabel('Blender 键盘输入', { exact: true }).press('Control+a'); await page.keyboard.type(marker + '_volatile'); await desktopKey(page, 'Enter', 'Enter'); await expect.poll(async () => (await readBackendMain(request, origin, own)).objects.map(object => object.name), { timeout: 15000 }).toContain(marker + '_volatile');
|
|
const owned = fs.readdirSync('/proc').filter(pid => { if(!/^\d+$/.test(pid))return false; try{return fs.realpathSync(`/proc/${pid}/exe`) === fs.realpathSync(process.env.BLENDER_BIN!) && fs.readFileSync(`/proc/${pid}/cmdline`, 'utf8').split('\0').includes(initial.workspace);}catch{return false;} });
|
|
expect(owned).toHaveLength(1); const pid = Number(owned[0]); expect(fs.readFileSync(`/proc/${pid}/cmdline`, 'utf8').split('\0')).toContain(initial.workspace);
|
|
record('abruptLoss', { pid, signal: 'SIGKILL', volatileMain: await readBackendMain(request, origin, own) }); process.kill(pid, 'SIGKILL');
|
|
}else{await desktopKey(page, 'Control+q', 'KeyQ'); await page.getByLabel('Blender 键盘输入', { exact: true }).press('d');}
|
|
await expect(page.locator('main.desktop-workspace')).toHaveAttribute('data-session-status', 'DISCONNECTED', { timeout: 30000 });
|
|
expect(await checkpointIdentity(page)).toEqual(checkpoint); record('nativeExited', { checkpointUnchanged: true, workspaceRetained: fs.existsSync(initial.workspace), quitRetained: fs.existsSync(path.join(initial.workspace, 'quit.blend')) });
|
|
if(evicted){const archived = info.outputPath('evicted-native-autosave.blend'); fs.renameSync(autosave, archived); expect(fs.existsSync(autosave)).toBe(false); expect(createHash('sha256').update(fs.readFileSync(archived)).digest('hex')).toBe((evidence.lastAutosave as { sha256: string }).sha256); record('autosaveEvicted', { path: autosave, archived, checkpoint });}
|
|
const resumed = await startDesktop(page, handles, () => page.getByRole('button', { name: '重新连接', exact: true }).click());
|
|
expect(resumed.id !== own.id && resumed.token !== own.token).toBe(true); const expired = await request.get(`${origin}/api/blender-session?id=${encodeURIComponent(own.id)}`, { headers: headers(own, 'files', origin) }); expect(expired.status()).toBe(404);
|
|
const receiver = await readBackendMain(request, origin, resumed); expect(receiver.workspace).toBe(initial.workspace); expect(fs.existsSync(path.join(receiver.workspace, 'quit.blend'))).toBe(!abrupt); expect(receiver.objects).toEqual(initial.objects); record('checkpointReceiver', receiver);
|
|
assertImages(receiver);
|
|
const restoredBounds = await image.boundingBox(); expect(restoredBounds).not.toBeNull(); await page.mouse.move(restoredBounds!.x + restoredBounds!.width * 0.41, restoredBounds!.y + restoredBounds!.height * 0.45);
|
|
await desktopKey(page, 'F3', 'F3'); await page.keyboard.type(abrupt ? 'recover auto save' : 'recover last session'); await desktopKey(page, 'Enter', 'Enter');
|
|
if(abrupt){
|
|
if(!evicted)expect(createHash('sha256').update(fs.readFileSync(autosave)).digest('hex')).toBe((evidence.lastAutosave as { sha256: string }).sha256);
|
|
const fileBrowser = async () => { const response = await request.get(`${origin}/api/blender-session?id=${encodeURIComponent(resumed.id)}`, { headers: headers(resumed, 'files', origin) }); expect(response.ok()).toBe(true); return (await response.json()).status.fileBrowser; };
|
|
await expect.poll(fileBrowser, { timeout: 15000 }).toBe(true); await page.screenshot({ path: info.outputPath('native-autosave-selector.png') });
|
|
if(evicted){await desktopKey(page, 'Escape', 'Escape'); record('missingAutosaveCancelled', true);}else{const picked = page.waitForResponse(response => response.request().headers()['x-blender-session-action'] === 'events' && response.request().postDataJSON()?.events?.some((event: any) => event.type === 'button' && event.down === false)); await page.mouse.dblclick(restoredBounds!.x + 450 * restoredBounds!.width / 1440, restoredBounds!.y + 130 * restoredBounds!.height / 900); expect((await picked).ok()).toBe(true);}
|
|
await expect.poll(fileBrowser, { timeout: 30000 }).toBe(false);
|
|
}
|
|
await expect.poll(async () => (await readBackendMain(request, origin, resumed)).objects, { timeout: 15000 }).toEqual(evicted ? initial.objects : edited.objects);
|
|
const recovered = await readBackendMain(request, origin, resumed); expect(recovered.filepath).toBe(evicted ? receiver.filepath : path.join(recovered.workspace, relativeMain)); expect(recovered.filepathExists).toBe(true); record(evicted ? 'checkpointFallback' : 'recovered', recovered);
|
|
assertImages(recovered);
|
|
expect((await readBackendMain(peerContext.request, origin, other)).objects).toEqual(peerBefore.objects); const denied = await peerContext.request.get(`${origin}/api/blender-session?id=${encodeURIComponent(resumed.id)}`, { headers: headers(other, 'files', origin) }); expect(denied.status()).toBe(404);
|
|
const ordinaryAfter = await request.get(`${origin}/api/blender-session?id=${encodeURIComponent(resumed.id)}&file=${encodeURIComponent(ordinaryPath)}`, { headers: headers(resumed, 'download', origin) }); expect(ordinaryAfter.ok()).toBe(true); expect((await ordinaryAfter.body()).equals(ordinaryBytes)).toBe(true); record('ownershipRetained', { expiredStatus: expired.status(), foreignStatus: denied.status(), ordinaryUnchanged: true });
|
|
} finally {
|
|
for (const handle of handles) { const closed = await request.post(`${origin}/api/blender-session`, { headers: headers(handle, 'close', origin), data: handle }); expect([200, 404]).toContain(closed.status()); }
|
|
await peerContext.close();
|
|
}
|
|
});
|