Replace project with current server release and Windows setup
Import the 2026-10-09 source release, Fastify/SQLite backend, restored source assets, and analytics-enabled server build. Replace files from the previous release while retaining the existing repository history. Make builds portable across Windows and Linux, configure local startup, normalize Windows static asset paths, and fix test fixture cleanup and file URL handling. Exclude local environment settings, databases, dependencies, validation output, and TypeScript build cache from version control. Validation: npm ci, static and server builds, 106 arrival tests, and actual local HTTP checks passed. The full suite passed 467 of 470 tests; three tests still require QA fixtures omitted from the supplied release, documented in README-서버실행.md.
This commit is contained in:
@@ -0,0 +1,149 @@
|
||||
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);
|
||||
});
|
||||
Reference in New Issue
Block a user