import assert from 'node:assert/strict'; import { createHash } from 'node:crypto'; import { readFile } from 'node:fs/promises'; import { test } from 'node:test'; import vm from 'node:vm'; import ts from 'typescript'; import { loadTypescript } from './support/load-typescript.mjs'; const main = await readFile(new URL('../src/main.ts', import.meta.url), 'utf8'); const html = await readFile(new URL('../index.html', import.meta.url), 'utf8'); const css = await readFile(new URL('../src/style.css', import.meta.url), 'utf8'); const ast = ts.createSourceFile('main.ts', main, ts.ScriptTarget.Latest, true); const statement = predicate => { const found = ast.statements.find(node => predicate(node, node.getText(ast))); assert.ok(found, 'required arrival lifecycle wiring is present'); return found.getText(ast); }; const fn = name => statement(node => ts.isFunctionDeclaration(node) && node.name?.text === name); const variable = name => statement(node => ts.isVariableStatement(node) && node.declarationList.declarations.some(declaration => declaration.name.getText(ast) === name)); const expression = prefix => statement((node, text) => ts.isExpressionStatement(node) && text.startsWith(prefix)); const deferred = () => { let resolve, reject; const promise = new Promise((a, b) => { resolve = a; reject = b; }); return { promise, resolve, reject }; }; const tick = async () => { for (let index = 0; index < 25; index++) await Promise.resolve(); }; // These source contracts supplement executed production handlers below. Removing // the gate, moving arrival to generic ready, or awaiting telemetry breaks them. test('collector construction is explicitly opt-in and is started once outside rendering', () => { assert.match(variable('arrivalTracker'), /import\.meta\.env\.VITE_ARRIVAL_ANALYTICS === 'true'/); assert.equal((main.match(/arrivalTracker\?\.start\(\)/g) ?? []).length, 1); assert.doesNotMatch(fn('frame'), /arrivalTracker|\/api\/|makeDogId/); assert.doesNotMatch(main, /await\s+arrivalTracker/); }); test('AI success owns the only arrival call and captures a request-local token', () => { const ai = fn('generateAIDepth'); assert.equal((main.match(/arrivalTracker\?\.arrived\(/g) ?? []).length, 1); assert.match(ai, /const arrivalToken = arrivalJourneyFor\(sourceTexture\)/); assert.match(ai, /!renderLost && !meshFailure && arrivalToken !== null && arrivalToken === photoArrivalToken/); assert.ok(ai.indexOf('setDepth(texture)') < ai.indexOf('arrivalTracker?.arrived(arrivalToken)')); assert.ok(ai.indexOf('arrivalTracker?.arrived(arrivalToken)') < ai.indexOf("experience.setState('ready')")); assert.match(fn('teardown'), /arrivalTracker\?\.dispose\(\)/); }); test('photo snapshot comes from the name store and survives retry without rereading the name', () => { assert.match(fn('beginMediaLoad'), /petName\.name/); assert.doesNotMatch(fn('arrivalJourneyFor'), /petName\.|querySelector|\.value/); assert.match(fn('invalidateDepthRequests'), /cancelArrivalJourney\(\)/); assert.match(fn('showMeshFailure'), /cancelArrivalJourney\(\)/); assert.doesNotMatch(fn('generateAIDepth'), /beginMediaLoad|petNameForms/); }); test('two initially hidden counts sit in the loading footer and below the settings heading', () => { const nodes = [...html.matchAll(/]*data-arrival-count[^>]*>/g)]; assert.equal(nodes.length, 2); for (const [node] of nodes) { assert.match(node, /\bhidden\b/); assert.doesNotMatch(node, /aria-live|role=/); } const welcome = html.match(/
/)[0]; const loading = html.match(/
/)[0]; assert.doesNotMatch(welcome, /data-arrival-count/); assert.match(loading, /]*data-arrival-count[^>]*><\/p>\s*<\/section>/); assert.match(html, /id="settings-title"[\s\S]*?<\/div>\s*]*data-arrival-count/); assert.match(css, /\.arrival-count\s*\{[^}]*(?:font-size|line-height)/); }); class Element { dataset = {}; attributes = {}; listeners = new Map(); hidden = false; open = false; inert = false; value = ''; isConnected = true; style = {}; addEventListener(type, callback, { signal } = {}) { const set = this.listeners.get(type) ?? new Set(); set.add(callback); this.listeners.set(type, set); signal?.addEventListener('abort', () => set.delete(callback)); } async dispatch(type, extra = {}) { for (const callback of [...(this.listeners.get(type) ?? [])]) await callback({ target: this, ...extra }); } setAttribute(key, value) { this.attributes[key] = value; } removeAttribute(key) { delete this.attributes[key]; } focus() { if (!this.inert) this.root.activeElement = this; } showModal() { this.open = true; } close() { this.open = false; void this.dispatch('close'); } getBoundingClientRect() { return { left: 0, top: 0, right: 400, bottom: 800 }; } remove() {} } async function harness({ enabled = true, reduced = false, pendingHash = false, retryArrival = false, cryptoFailure = '', invalidInitialId = false, blockedStorage = false } = {}) { // Assert the actual feature before importing its separate production modules. const initialization = ['arrivalTracker', 'photoArrivalSnapshot', 'photoArrivalTexture', 'photoArrivalToken'].map(variable).join('\n'); const identity = await loadTypescript(new URL('../src/analytics/Identity.ts', import.meta.url)); const { createArrivalTracker } = await loadTypescript(new URL('../src/analytics/ArrivalTracker.ts', import.meta.url)); const { createTransport } = await loadTypescript(new URL('../src/analytics/Transport.ts', import.meta.url)); const { renderArrivalCount } = await loadTypescript(new URL('../src/ui/ArrivalCount.ts', import.meta.url)); const { createPetNameStore } = await loadTypescript(new URL('../src/state/PetName.ts', import.meta.url)); const { initExperienceUI } = await loadTypescript(new URL('../src/ui/ExperienceUI.ts', import.meta.url)); const root = new Element(), motion = new Element(), timers = new Map(); let timerId = 0; motion.matches = reduced; const elements = Object.fromEntries([...html.matchAll(/\bid="([^"]+)"/g)].map(([, id]) => [id, new Element()])); const countNodes = [new Element(), new Element()]; countNodes.forEach(node => { node.hidden = true; }); for (const node of Object.values(elements)) node.root = root; root.querySelector = selector => elements[selector.slice(1)] ?? null; root.querySelectorAll = selector => selector === '[data-arrival-count]' ? countNodes : []; root.documentElement = new Element(); root.defaultView = { localStorage: { getItem() { return null; }, setItem() {} }, matchMedia: () => motion, setTimeout(callback, ms) { timers.set(++timerId, { callback, ms }); return timerId; }, clearTimeout(id) { timers.delete(id); } }; const listenerAbort = new AbortController(); const petName = createPetNameStore(() => root.defaultView.localStorage); petName.setName('호롱'); const experience = initExperienceUI(root, listenerAbort.signal, () => {}, () => {}, () => {}, petName); const requests = [], hashes = [], sleeps = [], inferences = [], sampleFetches = [], mediaLoads = []; let identityReads = 0, transportCreations = 0, collectorCreations = 0, uuidCalls = 0, storageReads = 0, storageWrites = 0, digestCalls = 0; const noop = () => {}; const disposable = () => ({ dispose: noop }); class Photo { width = 400; height = 500; src = 'blob:photo'; async decode() {} } const texture = () => ({ ...disposable(), image: new Photo() }); const emptyTex = texture(); const context = { document: root, listenerAbort, petName, experience, HTMLImageElement: Photo, Image: Photo, AbortController, Object, Array, Error, Promise, Number, Boolean, Math, performance, THREE: { SRGBColorSpace: 'srgb', Texture: class { constructor(image) { this.image = image; } } }, AI_DEPTH: { modelName: 'Small', inputSize: 770 }, active: emptyTex, emptyTex, activeIsVideo: false, aspect: 0, mediaRequest: 0, depthRequest: 0, mediaBusy: false, manualDepthBusy: false, aiDepthBusy: false, renderLost: false, disposed: false, meshFailure: false, pendingCutout: null, cutout: null, cutoutTexture: null, cutoutEnabled: false, pendingSampleFraming: false, pendingPairDepth: null, pendingSampleDebugImage: null, pendingTestPhotoHasNoDepth: false, activeTestPhotoHasNoDepth: false, aiDepthButton: elements['ai-depth'], imageInput: elements.image, videoInput: elements.video, depthFileInput: elements['depth-image'], depthProvenance: elements['depth-provenance'], sampleButton: elements.sample, testPhotoInput: { value: '' }, retry: new Element(), debugOutput: new Element(), hintElement: { dataset: {} }, depthUniforms: { uTexture: {}, uDepthMap: { value: disposable() } }, depthMap: disposable(), petMat: disposable(), petDepth: { geometry: disposable(), setVideo: noop }, empty: { style: {} }, depthDebug: { clear: noop, setSample: noop }, DepthProcessor: { flatTexture: texture, toTexture: texture }, setDepth(value) { context.appliedDepth = value; }, setMode: noop, setStatus: noop, showHint: noop, resetViewCalls:0,resetNewPhotoView(){context.resetViewCalls++;},syncPhotoControls: noop, resize: noop, clearCutout: noop, renderPhotoTexture: () => context.active, cancelDoorInteraction: noop, resetHintForMedia: noop, hideHintAfterDrag: noop, clearPendingTestState: noop, clearPendingPair: noop, peekingUI: { cancel: noop, setState: noop, dispose: noop }, gyro: { setPaused: noop, dispose: noop }, aiDepthProvider: { cancel: noop, getDepthMap(image) { const pending = deferred(); inferences.push({ ...pending, image }); return pending.promise; } }, MediaLoader: class { constructor(ready, status) { this.ready = ready; this.status = status; context.loaderInstance = this; } load(file) { mediaLoads.push(file); } cancelPending() {} retry() {} dispose() {} }, RenderRecovery: class { constructor(_canvas, _dialog, _button, lost, restored) { context.loseGraphics = lost; context.restoreGraphics = restored; } dispose() {} }, fetch(path) { const pending = deferred(); sampleFetches.push({ ...pending, path }); return pending.promise; }, File: class { constructor(_, name, options) { this.name = name; this.type = options.type; } }, URL: { createObjectURL: () => 'blob:depth', revokeObjectURL: noop }, sampleDepthProvider: { getDepthMap: async () => texture() }, rendererSelectionGeneration: 0, comparisonGeneration: 0, requestedRenderer: 'b', selectedRenderer: 'b', comparisonRequested: false, comparison: null, meshErrorDialog: elements['mesh-render-error'], endComparison: noop, originalRenderer: null, meshRenderer: { restore: noop, dispose: noop }, renderer: { domElement: new Element(), dispose: noop }, rendererSelect: {}, rendererNotice: {}, courtyard: disposable(), peekingGrid: disposable(), doorMotion: { cancel: noop }, raf: 0, cancelAnimationFrame: noop, observer: { disconnect: noop }, effectInput: {}, sensitivityInput: {}, resetButton: {}, gyroButton: {}, pointer: disposable(), blenderFrame: disposable(), houseSurround: disposable(), housePhotoDepth: disposable(), windowFinish: disposable(), windowReveal: disposable(), windowRevealMaterial: disposable(), frameMaterials: [], petDepthMaterial: disposable(), geometry: disposable(), frameGeometry: disposable(), getVisitorId(options) { identityReads++; return identity.getVisitorId({ ...options, getStorage() { storageReads++; if (blockedStorage) throw new Error('storage unavailable'); const storage = options.getStorage(); return { getItem(key) { return invalidInitialId ? 'invalid stored id' : storage?.getItem(key); }, setItem(key, value) { storageWrites++; storage?.setItem(key, value); } }; } }); }, makeDogId(visitor, name, digest) { const id = createHash('sha256').update(JSON.stringify([1, visitor, name.normalize('NFC').trim() || '멍멍이'])).digest('hex'); const pending = deferred(); hashes.push({ visitor, name, id, ...pending }); return pendingHash ? pending.promise : identity.makeDogId(visitor, name, digest); }, createTransport() { transportCreations++; return createTransport({ fetch: async (path, init = {}) => { requests.push({ path, body: init.body && JSON.parse(init.body), signal: init.signal }); return { ok: !(retryArrival && path === '/api/arrivals'), status: retryArrival && path === '/api/arrivals' ? 503 : init.body ? 204 : 200, json: async () => ({ count: 7 }) }; }, sleep: (ms, signal) => { const pending = deferred(); sleeps.push({ ...pending, ms, signal }); return pending.promise; }, retryDelays: [1, 2] }); }, createArrivalTracker(options) { collectorCreations++; return createArrivalTracker(options); }, renderArrivalCount, }; // Test doubles are limited to external media/GPU/browser facilities. Lifecycle // logic, retry transport, token cancellation, count renderer and UI are real. const crypto = { get randomUUID() { if (cryptoFailure === 'randomUUID') throw new Error('crypto UUID unavailable'); return () => { uuidCalls++; return '86f755c0-921b-4d34-a09c-5fcc8e4a21c6'; }; }, get subtle() { if (cryptoFailure === 'subtle') throw new Error('subtle unavailable'); return { get digest() { if (cryptoFailure === 'digest') throw new Error('digest unavailable'); return (algorithm, data) => { digestCalls++; assert.equal(algorithm, 'SHA-256'); return Promise.resolve(Uint8Array.from(createHash('sha256').update(Buffer.from(data)).digest()).buffer); }; } }; }, }; Object.defineProperty(context, 'crypto', { get() { if (cryptoFailure === 'crypto') throw new Error('crypto unavailable'); return crypto; } }); context.meshErrorDialog ??= new Element(); vm.createContext(context); const functions = ['loadPersonalImage', 'cancelArrivalJourney', 'arrivalJourneyFor', 'beginMediaLoad', 'applyMedia', 'invalidateDepthRequests', 'syncAIDepthButton', 'generateAIDepth', 'loadSample', 'showMeshFailure', 'teardown'].map(fn); const handlers = [expression('imageInput.onchange'), expression('videoInput.onchange'), expression("depthFileInput?.addEventListener('change'"), expression("document.querySelector('#flow-flat')"), expression("document.querySelector('#flow-retry')")]; const code = [initialization, ...functions, variable('loader'), variable('renderRecovery'), ...handlers, expression('arrivalTracker?.start')].join('\n').replaceAll('import.meta.env.VITE_ARRIVAL_ANALYTICS', JSON.stringify(enabled ? 'true' : 'false')); vm.runInContext(ts.transpileModule(code, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext } }).outputText, context); await tick(); const accept = (kind = 'personal') => { const input = kind === 'video' ? context.videoInput : context.imageInput; input.onchange({ target: { files: [{ type: kind === 'video' ? 'video/mp4' : 'image/png' }], value: 'selected' } }); const photo = texture(); context.loaderInstance.ready(photo, 0.8, kind === 'video' ? {} : null); return photo; }; const finishAI = async (index = inferences.length - 1) => { inferences[index].resolve(texture()); await tick(); }; const respondSample = async () => { sampleFetches.at(-1).resolve({ ok: true, blob: async () => ({}), headers: { get: () => 'image/jpeg' } }); await tick(); }; return { context, elements, countNodes, timers, requests, hashes, sleeps, inferences, sampleFetches, mediaLoads, accept, finishAI, respondSample, arrivals: () => requests.filter(request => request.path === '/api/arrivals'), counters: () => ({ identityReads, transportCreations, collectorCreations, uuidCalls, storageReads, storageWrites, digestCalls }) }; } test('disabled static build creates no collector, identity, hash, storage or API work', async () => { const h = await harness({ enabled: false }); h.accept(); await h.finishAI(); assert.deepEqual(h.counters(), { identityReads: 0, transportCreations: 0, collectorCreations: 0, uuidCalls: 0, storageReads: 0, storageWrites: 0, digestCalls: 0 }); assert.equal(h.requests.length, 0); assert.equal(h.hashes.length, 0); assert.ok(h.countNodes.every(node => node.hidden)); assert.equal(h.elements.experience.dataset.flow, 'ready'); h.context.teardown(); }); test('valid AI success counts immediately for normal and reduced motion, without waiting for presentation', async () => { for (const reduced of [false, true]) { const h = await harness({ reduced }); h.accept(); await h.finishAI(); assert.equal(h.arrivals().length, 1); assert.deepEqual(Object.keys(h.arrivals()[0].body), ['dog_id']); assert.equal(h.elements.experience.dataset.flow, 'ready'); assert.equal(h.timers.size, reduced ? 0 : 1); assert.equal(h.countNodes[0].textContent, h.countNodes[1].textContent); assert.ok(h.countNodes.every(node => !node.hidden)); assert.equal(h.requests.filter(request => request.path === '/api/visits').length, 1); h.context.teardown(); } }); test('picker cancellation creates no journey, while retry retains the accepted photo name', async () => { const h = await harness(); h.context.imageInput.onchange({ target: { files: [], value: '' } }); assert.equal(h.inferences.length, 0); h.accept(); h.context.petName.setName('초코'); h.inferences[0].reject(new Error('AI unavailable')); await tick(); assert.equal(h.arrivals().length, 0); const retry = h.context.generateAIDepth(); await h.finishAI(); await retry; assert.equal(h.hashes.at(-1).name, '호롱'); assert.equal(h.arrivals().length, 1); h.accept(); await h.finishAI(); assert.equal(h.hashes.at(-1).name, '초코'); assert.equal(h.arrivals().length, 2); h.context.teardown(); }); test('all replacement samples stay excluded after successful AI', async () => { for (const selected of ['dog1', 'dog2', 'dog3', 'dog4', 'dog5']) { const h = await harness(), loading = h.context.loadSample(selected); await h.respondSample(); await loading; h.context.loaderInstance.ready({ image: new h.context.HTMLImageElement(), dispose() {} }, 0.8, null); await h.finishAI(); assert.equal(h.elements.experience.dataset.flow, 'ready'); assert.equal(h.arrivals().length, 0); assert.equal(h.hashes.length, 0); assert.equal(h.requests.filter(request => request.path === '/api/visits').length, 1); h.context.teardown(); } }); test('video, manual-depth, flat-view and errors do not create arrivals', async () => { const video = await harness(); video.accept('video'); assert.equal(video.inferences.length, 0); assert.equal(video.arrivals().length, 0); video.context.teardown(); for (const action of ['manual', 'flat', 'media-error', 'AI-error']) { const h = await harness(); h.accept(); if (action === 'manual') { h.context.depthFileInput.files = [{}]; await h.context.depthFileInput.dispatch('change'); } if (action === 'flat') await h.elements['flow-flat'].dispatch('click'); if (action === 'media-error') h.context.loaderInstance.status('cannot load'); if (action === 'AI-error') { h.inferences[0].reject(new Error('failed')); await tick(); } else await h.finishAI(); assert.equal(h.arrivals().length, 0, action); h.context.teardown(); } }); test('photo replacement and stale AI replies cannot switch to a newer identity', async () => { const h = await harness(); h.accept(); h.context.petName.setName('초코'); h.accept(); await h.finishAI(0); assert.equal(h.arrivals().length, 0); await h.finishAI(1); assert.equal(h.arrivals().length, 1); assert.equal(h.hashes.at(-1).name, '초코'); h.context.teardown(); }); test('failed replacement cannot rearm a new name for the old active texture', async () => { const h = await harness(); h.accept(); h.inferences[0].reject(new Error('failed')); await tick(); h.context.petName.setName('초코'); h.context.imageInput.onchange({ target: { files: [{ type: 'image/png' }], value: 'new' } }); h.context.loaderInstance.status('new photo failed'); const retry = h.context.generateAIDepth(); await h.finishAI(); await retry; assert.equal(h.arrivals().length, 0); h.context.teardown(); }); test('graphics loss, render failure and teardown invalidate in-flight AI eligibility', async () => { for (const action of ['context-loss', 'mesh-failure', 'dispose']) { const h = await harness(); h.accept(); if (action === 'context-loss') h.context.loseGraphics(); if (action === 'mesh-failure') h.context.showMeshFailure(); if (action === 'dispose') h.context.teardown(); await h.finishAI(); assert.equal(h.arrivals().length, 0, action); if (action !== 'dispose') h.context.teardown(); } }); test('photo replacement, flat view, manual depth, errors and losses cancel a pending hash', async () => { for (const action of ['replacement', 'flat', 'manual', 'media-error', 'context-loss', 'mesh-failure', 'dispose']) { const h = await harness({ pendingHash: true }); h.accept(); await h.finishAI(); assert.equal(h.hashes.length, 1); if (action === 'replacement') h.accept(); if (action === 'flat') await h.elements['flow-flat'].dispatch('click'); if (action === 'manual') { h.context.depthFileInput.files = [{}]; await h.context.depthFileInput.dispatch('change'); } if (action === 'media-error') h.context.loaderInstance.status('cannot load'); if (action === 'context-loss') h.context.loseGraphics(); if (action === 'mesh-failure') h.context.showMeshFailure(); if (action === 'dispose') h.context.teardown(); h.hashes[0].resolve(h.hashes[0].id); await tick(); assert.equal(h.arrivals().length, 0, action); if (action !== 'dispose') h.context.teardown(); } }); test('invalidation aborts a delayed network retry without delaying the ready UI', async () => { for (const action of ['replacement', 'flat', 'manual', 'media-error', 'context-loss', 'mesh-failure', 'dispose']) { const h = await harness({ retryArrival: true }); h.accept(); await h.finishAI(); assert.equal(h.elements.experience.dataset.flow, 'ready'); assert.equal(h.arrivals().length, 1); assert.equal(h.sleeps.length, 1); if (action === 'replacement') h.accept(); if (action === 'flat') await h.elements['flow-flat'].dispatch('click'); if (action === 'manual') { h.context.depthFileInput.files = [{}]; await h.context.depthFileInput.dispatch('change'); } if (action === 'media-error') h.context.loaderInstance.status('cannot load'); if (action === 'mesh-failure') h.context.showMeshFailure(); if (action === 'context-loss') h.context.loseGraphics(); if (action === 'dispose') h.context.teardown(); assert.equal(h.sleeps[0].signal.aborted, true, action); h.sleeps[0].resolve(); await tick(); assert.equal(h.arrivals().length, 1, action); if (action !== 'dispose') h.context.teardown(); } }); test('invalid stored identity and unavailable storage recover without affecting AI startup', async () => { for (const options of [{ invalidInitialId: true }, { blockedStorage: true }]) { const h = await harness(options); h.accept(); await h.finishAI(); assert.equal(h.elements.experience.dataset.flow, 'ready'); assert.equal(h.arrivals().length, 1); assert.equal(h.counters().uuidCalls, 1); h.context.teardown(); } }); test('throwing crypto, randomUUID, subtle and digest getters leave the experience usable', async () => { for (const cryptoFailure of ['crypto', 'randomUUID', 'subtle', 'digest']) { const h = await harness({ cryptoFailure }); h.accept(); await h.finishAI(); assert.equal(h.elements.experience.dataset.flow, 'ready', cryptoFailure); assert.equal(h.arrivals().length, 0, cryptoFailure); h.context.teardown(); } }); test('explicit AI retry after flat, manual depth or graphics recovery re-arms the original photo snapshot', async () => { for (const reason of ['flat', 'manual', 'context-loss']) { const h = await harness(); h.accept(); h.inferences[0].reject(new Error('initial inference failed')); await tick(); if (reason === 'flat') await h.elements['flow-flat'].dispatch('click'); if (reason === 'manual') { h.context.depthFileInput.files = [{}]; await h.context.depthFileInput.dispatch('change'); } if (reason === 'context-loss') { h.context.loseGraphics(); h.context.restoreGraphics(); } h.context.petName.setName('보리'); const retry = h.context.generateAIDepth(); await h.finishAI(); await retry; assert.equal(h.arrivals().length, 1, reason); assert.equal(h.hashes.at(-1).name, '호롱', reason); h.context.teardown(); } }); test('new-photo reset is called once on acceptance, never by picker cancellation, failure or AI retry',async()=>{ const h=await harness({reduced:true});h.accept();await h.finishAI();assert.equal(h.context.resetViewCalls,1); h.context.imageInput.onchange({target:{files:[],value:''}});assert.equal(h.context.resetViewCalls,1); const retry=h.context.generateAIDepth();await h.finishAI();await retry;assert.equal(h.context.resetViewCalls,1); h.context.imageInput.onchange({target:{files:[{type:'image/png'}],value:'new'}});h.context.loaderInstance.status('failed decode');assert.equal(h.context.resetViewCalls,1); const sample=h.context.loadSample('dog5');await h.respondSample();await sample;assert.equal(h.context.resetViewCalls,1); h.context.loaderInstance.ready({...h.context.active,image:new h.context.HTMLImageElement()},.8,null);await h.finishAI();assert.equal(h.context.resetViewCalls,2); h.context.teardown(); });