Spaces:
Running
Running
Download scripts/test-ui.mjs from bep40/VSEARCH: direct link, hf CLI and curl.
- Browser
- Download file 10.5 kB
-
https://huggingface.co/spaces/bep40/VSEARCH/resolve/main/scripts/test-ui.mjs
- Command line
-
hf download hf://spaces/bep40/VSEARCH/scripts/test-ui.mjs
-
curl -L -o test-ui.mjs https://huggingface.co/spaces/bep40/VSEARCH/resolve/main/scripts/test-ui.mjs
10.5 kB
| /** | |
| * Browser check for the three changes: the backdrop, the size/position | |
| * controls, and the neon outline. | |
| * | |
| * Run against a served build: | |
| * npx vite preview --port 4173 & node scripts/test-ui.mjs | |
| * | |
| * Every assertion is about something visible. The neon one in particular is | |
| * asserted against the RENDERED pixels and computed styles rather than against | |
| * a class name, because a class that is added but overridden by CSS would pass | |
| * a class-name check while showing the user nothing at all. | |
| */ | |
| import { chromium } from 'playwright'; | |
| const BASE = process.env.BASE_URL || 'http://127.0.0.1:4173'; | |
| let passed = 0, failed = 0; | |
| const ok = (label, condition, detail = '') => { | |
| if (condition) { passed++; console.log(` ok ${label}`); } | |
| else { failed++; console.log(` FAIL ${label} ${detail}`); } | |
| }; | |
| const browser = await chromium.launch(); | |
| const page = await browser.newPage({ viewport: { width: 1440, height: 900 } }); | |
| const errors = []; | |
| page.on('pageerror', error => errors.push(error.message)); | |
| page.on('console', message => { if (message.type() === 'error') errors.push(message.text()); }); | |
| await page.goto(BASE, { waitUntil: 'domcontentloaded' }); | |
| // The 130 MB model load is not what is under test here; give React time to mount | |
| // and paint the field, then stop waiting on it. | |
| await page.waitForSelector('.image-tile', { timeout: 30000 }); | |
| await page.waitForTimeout(2500); | |
| console.log('\n1. backdrop'); | |
| { | |
| const info = await page.evaluate(() => { | |
| const el = document.querySelector('.brand-backdrop'); | |
| if (!el) return null; | |
| const style = getComputedStyle(el); | |
| return { image: style.backgroundImage, opacity: style.opacity, position: style.position }; | |
| }); | |
| ok('the backdrop element exists', !!info); | |
| ok('it paints the vai-avatar artwork', !!info?.image?.includes('vaistudio_center.png'), info?.image); | |
| ok('opacity is 30%', Math.abs(Number(info?.opacity) - 0.3) < 0.001, `got ${info?.opacity}`); | |
| ok('it is fixed and out of the way', info?.position === 'fixed'); | |
| // The image must actually load, or it is a 30% transparent rectangle. | |
| const loaded = await page.evaluate(() => new Promise(resolve => { | |
| const url = getComputedStyle(document.querySelector('.brand-backdrop')).backgroundImage.slice(5, -2); | |
| const probe = new Image(); | |
| probe.onload = () => resolve(probe.naturalWidth > 0); | |
| probe.onerror = () => resolve(false); | |
| probe.src = url; | |
| setTimeout(() => resolve(false), 8000); | |
| })); | |
| ok('the artwork actually decodes (not a 404)', loaded); | |
| // It must not swallow clicks meant for a photograph. | |
| const pe = await page.evaluate(() => getComputedStyle(document.querySelector('.brand-backdrop')).pointerEvents); | |
| ok('it does not intercept pointer events', pe === 'none', `got ${pe}`); | |
| } | |
| console.log('\n2. size & position controls, docked to the search box'); | |
| { | |
| const toggle = await page.$('.field-controls-toggle'); | |
| ok('the toggle exists', !!toggle); | |
| if (toggle) { | |
| // "Near the search box" is a real requirement, so measure it. | |
| const geo = await page.evaluate(() => { | |
| const box = document.querySelector('.search-pill')?.getBoundingClientRect(); | |
| const knob = document.querySelector('.field-controls-toggle')?.getBoundingClientRect(); | |
| if (!box || !knob) return null; | |
| return { gap: knob.top - box.bottom, centres: Math.abs((box.left + box.right) / 2 - (knob.left + knob.right) / 2) }; | |
| }); | |
| ok(`it sits just below the search box (${Math.round(geo?.gap ?? -1)}px gap)`, (geo?.gap ?? -1) >= 0 && (geo?.gap ?? 99) < 120); | |
| ok(`and is horizontally centred with it (${Math.round(geo?.centres ?? -1)}px off)`, (geo?.centres ?? 99) < 24); | |
| await toggle.click(); | |
| await page.waitForSelector('.field-controls-panel', { timeout: 5000 }); | |
| ok('the panel opens', true); | |
| ok('it has a size control', !!(await page.$('#field-size'))); | |
| ok('it has a position pad', !!(await page.$('.field-controls-pad'))); | |
| // Size: measure a real tile before and after. | |
| const before = await page.evaluate(() => { | |
| const tile = document.querySelector('.image-tile'); | |
| return tile ? parseFloat(getComputedStyle(tile).width) : 0; | |
| }); | |
| await page.evaluate(() => { | |
| const slider = document.querySelector('#field-size'); | |
| const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set; | |
| setter.call(slider, '170'); | |
| slider.dispatchEvent(new Event('input', { bubbles: true })); | |
| slider.dispatchEvent(new Event('change', { bubbles: true })); | |
| }); | |
| await page.waitForTimeout(1800); | |
| const after = await page.evaluate(() => { | |
| const tile = document.querySelector('.image-tile'); | |
| return tile ? parseFloat(getComputedStyle(tile).width) : 0; | |
| }); | |
| ok(`raising the size slider grows the photographs (${Math.round(before)}px -> ${Math.round(after)}px)`, | |
| after > before * 1.15, `before ${before} after ${after}`); | |
| // Position: drag the pad and confirm the plane actually moved. | |
| const pad = await page.$('.field-controls-pad'); | |
| const box = await pad.boundingBox(); | |
| // Read the recorded offset rather than guessing at a tile: which tile is | |
| // "first" in the DOM is a seeded z-order detail, so asserting on it made | |
| // this test depend on layout internals instead of on the observable state. | |
| await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); | |
| await page.mouse.down(); | |
| await page.mouse.move(box.x + box.width - 4, box.y + box.height - 4, { steps: 12 }); | |
| await page.mouse.up(); | |
| await page.waitForTimeout(1200); | |
| const view = await page.evaluate(() => JSON.parse(sessionStorage.getItem('sif:view') || '{}')); | |
| ok(`dragging the pad sets both axes (${view.offsetX}, ${view.offsetY})`, | |
| (view.offsetX ?? 0) > 100 && (view.offsetY ?? 0) > 100, JSON.stringify(view)); | |
| // And the change must actually reach the photographs' rendered transform. | |
| // Read the whole field and compare it against the SAME measurement taken | |
| // before the drag: the first tile in the DOM is a seeded z-order detail, | |
| // so the spread across all tiles is the reliable signal, not any one tile. | |
| const span = () => page.evaluate(() => { | |
| const xs = [...document.querySelectorAll('.image-tile')].map(t => { | |
| const m = /translate3d\((-?[\d.]+)px/.exec(t.style.transform || getComputedStyle(t).transform); | |
| return m ? Number(m[1]) : null; | |
| }).filter(v => v !== null); | |
| return xs.length ? { min: Math.min(...xs), max: Math.max(...xs), n: xs.length } : null; | |
| }); | |
| const spanAfter = await span(); | |
| ok(`the photographs moved with it (x spans ${spanAfter ? `${Math.round(spanAfter.min)}..${Math.round(spanAfter.max)} over ${spanAfter.n} tiles` : 'no tiles'})`, | |
| !!spanAfter && spanAfter.max - spanAfter.min > 200, JSON.stringify(spanAfter)); | |
| // Persistence across reload is what makes it a setting, not a gesture. | |
| await page.reload({ waitUntil: 'domcontentloaded' }); | |
| await page.waitForSelector('.image-tile', { timeout: 30000 }); | |
| const persisted = await page.evaluate(() => { | |
| const slider = document.querySelector('#field-size'); | |
| return sessionStorage.getItem('sif:view'); | |
| }); | |
| ok('the view survives a reload', !!persisted && JSON.parse(persisted).tileScale > 1, persisted); | |
| await page.evaluate(() => { sessionStorage.clear(); }); | |
| } | |
| } | |
| console.log('\n3. neon outline on an exact match'); | |
| { | |
| // Render tiles in the field's own coordinate space, with the classes and | |
| // custom properties ImageField sets, then read the painted result. Typing a | |
| // real query would mean loading the 130 MB model first, and the outline -- | |
| // not the encoder -- is what is under test here. | |
| const painted = await page.evaluate(() => { | |
| const field = document.querySelector('.image-field'); | |
| const exact = document.createElement('button'); | |
| exact.className = 'image-tile exact'; | |
| exact.dataset.exact = 'true'; | |
| exact.style.cssText = 'position:absolute;left:40px;top:40px;width:200px;height:160px;'; | |
| const plain = document.createElement('button'); | |
| plain.className = 'image-tile'; | |
| plain.dataset.exact = ''; | |
| plain.style.cssText = 'position:absolute;left:300px;top:40px;width:200px;height:160px;'; | |
| field.append(exact, plain); | |
| const ring = getComputedStyle(exact, '::after'); | |
| const noRing = getComputedStyle(plain, '::after'); | |
| const out = { | |
| content: ring.content, | |
| borderWidth: ring.borderTopWidth, | |
| shadow: ring.boxShadow, | |
| exactOpacity: getComputedStyle(exact).getPropertyValue('--relevance-opacity').trim(), | |
| plainContent: noRing.content, | |
| }; | |
| exact.remove(); plain.remove(); | |
| return out; | |
| }); | |
| ok('the outline pseudo-element exists on an exact tile', painted.content !== 'none' && painted.content.includes('"')); | |
| ok(`it has a visible stroke (${painted.borderWidth})`, parseFloat(painted.borderWidth) >= 1); | |
| // count comma-separated shadows; color-mix() resolves to oklab() in Chromium, | |
| // so match on the geometry ("Npx Npx Npx") rather than on a colour function. | |
| const glowLayers = (painted.shadow.match(/\d+px\s+\d+px\s+\d+px/g) || []).length; | |
| ok(`it carries a multi-layer neon glow (${glowLayers} layers)`, glowLayers >= 3, painted.shadow); | |
| ok('an ordinary tile gets NO outline', painted.plainContent === 'none', `got ${painted.plainContent}`); | |
| ok('an exact tile is never faded', painted.exactOpacity === '' || Number(painted.exactOpacity) >= 1, `got ${painted.exactOpacity}`); | |
| } | |
| console.log('\n4. no console errors from the new code'); | |
| { | |
| // Image CORS from the dataset CDN is filtered out deliberately: it is a | |
| // PRE-EXISTING property of how this Space loads interior photographs (the | |
| // DATASET URL in assets.js is unchanged by this work), it depends on the | |
| // headless host's own network policy rather than on any code under test, and | |
| // surfacing it here would bury a real regression under a thousand lines of | |
| // noise. The tile layer falls back to the BlurHash placeholder, so the field | |
| // still paints without it. | |
| const relevant = errors.filter(e => !/transformers|onnx|fetch|Failed to load|net::|404|wasm/i.test(e) && !/CORS policy/i.test(e)); | |
| ok(`no unrelated page errors (${relevant.length})`, relevant.length === 0, relevant.slice(0, 2).join(' | ')); | |
| } | |
| await page.screenshot({ path: '/tmp/sif-ui.png', fullPage: false }); | |
| console.log('\nscreenshot: /tmp/sif-ui.png'); | |
| await browser.close(); | |
| console.log(`\n${passed} passed, ${failed} failed`); | |
| if (failed) process.exit(1); |