import assert from 'node:assert/strict'; import { test } from 'node:test'; import { readFile } from 'node:fs/promises'; import vm from 'node:vm'; import ts from 'typescript'; import { loadTypescript } from './support/load-typescript.mjs'; const maskModule = await loadTypescript(new URL('../src/cutout/CutoutMask.ts', import.meta.url)); const tick = async () => { for (let i = 0; i < 20; i++) await Promise.resolve(); }; async function harness({ delayedSnapshot = false, delayedPreview = false, previewError = false, encodeError = false } = {}) { const source = await readFile(new URL('../src/cutout/CutoutPreparation.ts', import.meta.url), 'utf8'); const exports = {}, captures = [], workers = [], images = [], blobs = [], revoked = [], canvases = []; const pixels = Uint8ClampedArray.of(200, 120, 20, 255, 10, 30, 90, 128, 44, 55, 66, 255, 11, 22, 33, 255); class Provider { constructor(onStatus) { this.onStatus = onStatus; this.cancelled = 0; workers.push(this); } run(snapshot, signal) { this.snapshot = snapshot; this.signal = signal; return new Promise((resolve, reject) => { this.resolve = resolve; this.reject = reject; signal.addEventListener('abort', () => reject(new DOMException('cancel', 'AbortError')), { once: true }); }); } cancel() { this.cancelled++; } dispose() { this.disposed = true; } } function capture(_file, id, signal) { const snapshot = { requestId: id, width: 2, height: 2, bitmap: { width: 2, height: 2, closes: 0, close() { this.closes++; } } }; const request = { snapshot, signal }; captures.push(request); return delayedSnapshot ? new Promise(resolve => { request.resolve = () => resolve(snapshot); }) : Promise.resolve(snapshot); } const document = { createElement(tag) { assert.equal(tag, 'canvas'); const canvas = { width: 0, height: 0, pixels: pixels.slice(), draws: [], getContext() { return { drawImage: image => canvas.draws.push(image), getImageData: () => ({ data: canvas.pixels.slice() }), putImageData: data => { canvas.pixels = data.data.slice(); }, }; }, toBlob(callback, type) { assert.equal(type, 'image/png', 'every adapter must be PNG, never SVG'); const blob = new Blob([canvas.pixels], { type }); blob.rgba = canvas.pixels.slice(); blobs.push(blob); callback(encodeError ? null : blob); } }; canvases.push(canvas); return canvas; } }; class Image { constructor() { images.push(this); } set src(value) { this._src = value; if (!delayedPreview) queueMicrotask(() => previewError ? this.onerror?.() : this.onload?.()); } get src() { return this._src; } removeAttribute(name) { assert.equal(name, 'src'); this._src = ''; } } const urls = []; vm.runInNewContext(ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } }).outputText, { exports, AbortController, Blob, DOMException, Error, Image, Set, Uint8Array, Uint8ClampedArray, document, URL: { createObjectURL(blob) { urls.push(blob); return `blob:png-${urls.length}`; }, revokeObjectURL(url) { revoked.push(url); } }, require(name) { if (name.includes('PhotoSnapshot')) return { createPhotoSnapshot: capture }; if (name.includes('CutoutMask')) return maskModule; if (name.includes('CutoutProvider')) return { CutoutProvider: Provider }; throw new Error(name); }, }); const preparation = new exports.CutoutPreparation(); function succeed(alpha = Uint8Array.of(0, 128, 255, 255)) { const p = workers[0]; p.resolve({ requestId: p.snapshot.requestId, width: 2, height: 2, alpha, bounds: { x0: 0, y0: 0, x1: 2, y1: 2 }, warnings: [], largeComponentCount: 1, run: { model: 'test' } }); } return { preparation, captures, workers, images, blobs, urls, revoked, canvases, pixels, succeed }; } test('preparation is lazy and produces review-only PNGs with full original RGB preserved', async () => { const h = await harness(); assert.equal(h.captures.length, 0); const progress = [], task = h.preparation.prepare(new Blob(['photo']), message => progress.push(message)); await tick(); assert.equal(h.workers[0].snapshot, h.captures[0].snapshot); h.workers[0].onStatus(h.captures[0].snapshot.requestId, 'mask ready'); h.succeed(); const result = await task; assert.equal(result.originalBlob.type, 'image/png'); assert.equal(result.cutoutBlob.type, 'image/png'); assert.deepEqual([...result.originalBlob.rgba], [...h.pixels]); assert.deepEqual([...result.cutoutBlob.rgba], [200,120,20,0,10,30,90,64,44,55,66,255,11,22,33,255]); assert.deepEqual([...result.alpha], [0,64,255,255], 'result alpha is the actual combined alpha'); assert.equal(result.originalImage.src, result.originalUrl); assert.equal(result.cutoutImage.src, result.cutoutUrl); assert.equal(result.width, 2); assert.equal(result.height, 2); assert.equal(result.requiresReview, true); assert.ok(progress.includes('mask ready')); assert.equal(h.captures[0].snapshot.bitmap.closes, 1); assert.ok(h.canvases.every(canvas => canvas.width === 1 && canvas.height === 1)); result.dispose(); result.dispose(); assert.equal(h.revoked.length, 2); assert.ok(h.images.every(image => image.src === '')); h.preparation.dispose(); assert.equal(h.revoked.length, 2); }); test('external cancellation terminates inference and cannot publish preview URLs', async () => { const h = await harness(), abort = new AbortController(); const task = h.preparation.prepare(new Blob(), undefined, abort.signal), rejected = assert.rejects(task, { name: 'AbortError' }); await tick(); abort.abort(); await rejected; assert.equal(h.workers[0].signal.aborted, true); assert.equal(h.captures[0].snapshot.bitmap.closes, 1); assert.equal(h.urls.length, 0); }); test('cancel while decoding closes a late snapshot and never starts segmentation', async () => { const h = await harness({ delayedSnapshot: true }); const task = h.preparation.prepare(new Blob()), rejected = assert.rejects(task, { name: 'AbortError' }); h.preparation.cancel(); h.captures[0].resolve(); await rejected; assert.equal(h.captures[0].snapshot.bitmap.closes, 1); assert.equal(h.workers[0].snapshot, undefined); assert.equal(h.urls.length, 0); }); test('replacement invalidates old work while a delivered result survives until disposal', async () => { const h = await harness(); const first = h.preparation.prepare(new Blob()), rejected = assert.rejects(first, { name: 'AbortError' }); await tick(); const second = h.preparation.prepare(new Blob()); await rejected; await tick(); h.succeed(); const result = await second; const third = h.preparation.prepare(new Blob()), thirdRejected = assert.rejects(third, { name: 'AbortError' }); assert.equal(h.revoked.length, 0, 'accepted old consumer owns its URLs through a replacement'); await tick(); h.preparation.dispose(); await thirdRejected; assert.equal(h.revoked.length, 2); assert.equal(result.originalImage.src, ''); assert.equal(h.workers[0].disposed, true); await assert.rejects(h.preparation.prepare(new Blob()), /종료|disposed/); }); test('preview PNG decoding failure revokes both URLs and releases the bitmap', async () => { const h = await harness({ previewError: true }); const task = h.preparation.prepare(new Blob()), rejected = assert.rejects(task, /미리보기|사진|PNG/); await tick(); h.succeed(); await rejected; assert.equal(h.captures[0].snapshot.bitmap.closes, 1); assert.equal(h.revoked.length, h.urls.length); assert.ok(h.images.every(image => image.src === '')); }); test('cancellation during pending PNG preview decode clears handlers and URLs', async () => { const h = await harness({ delayedPreview: true }), abort = new AbortController(); const task = h.preparation.prepare(new Blob(), undefined, abort.signal), rejected = assert.rejects(task, { name: 'AbortError' }); await tick(); h.succeed(); await tick(); assert.ok(h.urls.length > 0); abort.abort(); await rejected; assert.equal(h.revoked.length, h.urls.length); assert.ok(h.images.every(image => image.src === '' && image.onload === null && image.onerror === null)); }); test('PNG encoding failure does not return incomplete preview or leak snapshot', async () => { const h = await harness({ encodeError: true }); const task = h.preparation.prepare(new Blob()), rejected = assert.rejects(task, /PNG|미리보기|사진/); await tick(); h.succeed(); await rejected; assert.equal(h.captures[0].snapshot.bitmap.closes, 1); assert.equal(h.urls.length, 0); });