Capstone project. I'm ngl it's vibe-coded and it's only here so I can mess around with it
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request #8 from chriskalos/fix/testable-apps-discovery

Dynamic testable apps discovery

authored by

Chris and committed by
GitHub
6176c32c 5cced208

+177 -49
+27 -26
README.md
··· 1 1 # UXET 2 2 3 - **UX Evaluation Tool** — a browser-based framework for running usability tests with eye-tracking, interaction capture, and heatmap generation. Load any web app, give a user a task, and get back a full session debrief with gaze heatmaps and interaction stats. 3 + **UX Evaluation Tool** — a browser-based framework for running usability tests against same-origin web apps with webcam eye-tracking, interaction capture, heatmap generation, and deterministic offline analysis. 4 4 5 5 ## What it does 6 6 7 - 1. **Load** a testable web app into a sandboxed iframe. 7 + 1. **Load** a configured same-origin test app into an iframe. 8 8 2. **Calibrate** eye-tracking via webcam (powered by [WebGazer](https://webgazer.cs.brown.edu/)). 9 9 3. **Record** a session — gaze data, mouse movement, clicks, keypresses, and scroll events are all captured while the user completes a task. 10 - 4. **Debrief** — when the task is done (or a win condition fires automatically), UXET renders per-screen gaze heatmaps, v3 ranked findings, element-level issues, and interaction stats. 11 - 5. **Export / Import** session data as JSON so UXET can re-analyze prior sessions offline, including temporary cohort comparison from multiple imported files. 10 + 4. **Debrief** — when the task is done, either by app `postMessage` completion or manual stop, UXET renders per-screen gaze heatmaps, v3 ranked findings, element-level issues, data-quality warnings, and interaction stats. 11 + 5. **Export / Import** session data as JSON so UXET can re-analyze prior sessions offline, including temporary cohort comparison from multiple imported files or repeated live runs of the same app/task. 12 12 13 13 ## Getting started 14 14 ··· 40 40 41 41 Once the server is up, open the URL in your browser, usually `http://127.0.0.1:8080`. 42 42 43 - > **Requires:** `python3` (used for `python3 -m http.server`). That's it. 43 + > **Requires:** `python3` for the local static server. Real eye tracking also requires a modern browser with webcam permission and network access to the WebGazer/html2canvas CDNs loaded by `index.html`. 44 44 45 45 ### Run a test 46 46 47 - 1. Pick an app from the dropdown. 47 + 1. Pick one of the configured apps from the dropdown. 48 48 2. Click **Load App** — this launches eye-tracking calibration. 49 49 3. Follow the calibration prompts (look at each dot and click it). 50 50 4. When calibration passes, click **Start Test** to begin recording. 51 - 5. Complete the task. The session ends automatically when the win condition is met, or press **Shift+Escape** to end it manually. 52 - 6. Review the debrief screen — heatmaps, timing, click counts, fixation stats, etc. 51 + 5. Complete the task. The session ends automatically when the iframe app sends `UXET_TASK_COMPLETE`, or press **Shift+Escape** to end it manually. 52 + 6. Review the debrief screen — heatmaps, timing, click counts, fixation stats, ranked findings, element findings, and confidence warnings. 53 53 7. Click **Test Again** to rerun the same app from the debrief screen, or **Export Data** to download the session as JSON. 54 54 55 55 When you use **Test Again** on the same app, UXET keeps the completed live runs in memory and adds comparison insights after the second run. The comparison highlights repeated findings, repeated element-level patterns, and outlier sessions for that app/task until you reset or reload. ··· 60 60 2. Select a UXET JSON export. 61 61 3. UXET validates the file and renders the same debrief pipeline used for live sessions. 62 62 63 - To compare sessions, select multiple UXET JSON exports in the import dialog. UXET analyzes the files in memory, reports repeated findings, element patterns, outlier sessions, and data-quality warnings, then discards the cohort when you reset or reload. 63 + To compare sessions, select multiple UXET JSON exports in the import dialog. UXET analyzes the files in memory, reports repeated findings, element patterns, outlier sessions, and data-quality warnings, then discards the cohort when you reset or reload. Exporting from cohort mode downloads the cohort analysis rather than a single session artifact. 64 64 65 65 Legacy exports are still supported, but they may lack screenshots, dense mouse traces, or element snapshots. In those cases UXET shows fidelity warnings and limits the analysis accordingly. 66 66 ··· 74 74 75 75 ## Export schema 76 76 77 - New exports use `schemaVersion: "3"` and include: 77 + Session exports use `schemaVersion: "3"` and include: 78 78 79 79 - full `screenRecords` with screenshots, gaze points, interaction events, and element snapshots 80 + - summary `screens` for compatibility with older UXET exports 80 81 - `mouseTrace` sampled during recording 81 82 - `fixations` 82 83 - `calibration` 84 + - `debug` metadata, including whether calibration was skipped or mouse-derived gaze was used 83 85 - `analysisContext` 84 86 - recomputed deterministic `analysis` with ranked findings, confidence, screen metrics, and element metrics 85 87 ··· 93 95 94 96 Element-level findings are generated when exports include element snapshots or click fingerprints. Future recordings capture more clickable/card-like elements as areas of interest, including `[data-id]`, `[onclick]`, `[tabindex]`, and pointer-cursor elements. If element snapshots are missing, UXET falls back to spatial zone metrics and shows a confidence warning. 95 97 96 - ### Analysis tests 98 + ### Test status 97 99 98 - Run the lightweight module tests with: 100 + There is currently no committed automated test suite or build step in this repository. Validate changes by running the static server, completing at least one live or mouse-gaze session, importing/exporting JSON, and checking the browser console for runtime errors. 99 101 100 - ```bash 101 - node tests/analysis.test.js 102 - ``` 102 + ## Adding your own apps 103 103 104 - Or open `tests/run-analysis-tests.html` from the local server. 104 + UXET discovers apps dynamically from folders under `testable-apps/`. Each app folder must include: 105 105 106 - ## Adding your own apps 106 + - `index.html` for the app UI 107 + - `app.json` with the app name and task 107 108 108 - Drop your app into the `testable-apps/` directory and add a new `<option>` to the select in `index.html`: 109 + Example `testable-apps/your-app/app.json`: 109 110 110 - ```html 111 - <option 112 - value="testable-apps/your-app/index.html" 113 - data-task="Describe the task for the user" 114 - data-win="postMessage"> 115 - Your App Name 116 - </option> 111 + ```json 112 + { 113 + "name": "Your App Name", 114 + "task": "Describe the task for the user" 115 + } 117 116 ``` 118 117 119 - UXET now uses one standardized win condition: `postMessage`. When the task is complete, the app inside the iframe should send: 118 + UXET always uses the standardized automatic win condition: `postMessage`. Do not include win-condition metadata in `index.html` or `app.json`. When the task is complete, the app inside the iframe should send: 120 119 121 120 ```js 122 121 window.parent.postMessage({ type: 'UXET_TASK_COMPLETE' }, '*'); 123 122 ``` 124 123 125 124 This keeps UXET app integration task-specific inside the app while giving UXET only one completion signal to listen for. 125 + 126 + Because UXET directly reads iframe DOM, scroll, and event data, custom apps must be served from the same origin as UXET.
+1 -9
index.html
··· 93 93 <label class="field field-app" for="app-select"> 94 94 <span>App under test</span> 95 95 <select id="app-select"> 96 - <option value="">Select an app...</option> 97 - <option value="testable-apps/shop-app/index.html" data-task="Find and purchase a blue t-shirt" 98 - data-win="postMessage">THREAD Store</option> 99 - <option value="testable-apps/example-app/index.html" 100 - data-task="Fill out the feedback form with your details" 101 - data-win="postMessage">Feedback Terminal</option> 102 - <option value="testable-apps/long-page-app/index.html" 103 - data-task="Review the comparison sections and subscribe at the bottom of the page" 104 - data-win="postMessage">Signal Intelligence Report</option> 96 + <option value="">Loading apps...</option> 105 97 </select> 106 98 </label> 107 99
+52 -10
js/main.js
··· 10 10 import { createSessionArtifactFromLive } from './sessionArtifact.js'; 11 11 import { analyzeSessionArtifact } from './sessionAnalyzer.js'; 12 12 import { analyzeSessionCohort } from './cohortAnalyzer.js'; 13 + import { discoverTestableApps } from './testableApps.js'; 13 14 14 15 class UXETApp { 15 16 constructor() { ··· 21 22 this.bridge = new IframeBridge(); 22 23 this.winConditions = new WinConditionRegistry(); 23 24 this.importer = new SessionImporter(); 25 + this.availableApps = []; 24 26 this.selectedApp = null; 25 27 this.captureJobs = new Map(); 26 28 this.calibrationPassed = false; ··· 161 163 init() { 162 164 this.applyTheme(this.theme); 163 165 this.bindEvents(); 164 - this.selectApp(this.elements.appSelect); 165 166 this.bindCallbacks(); 166 167 this.syncDebugUi(); 167 168 this.updateUiForState('idle'); 169 + this.loadTestableApps(); 168 170 } 169 171 170 172 bindEvents() { ··· 274 276 }; 275 277 } 276 278 279 + async loadTestableApps() { 280 + this.elements.loadAppBtn.disabled = true; 281 + this.elements.appSelect.disabled = true; 282 + this.elements.appSelect.innerHTML = '<option value="">Loading apps...</option>'; 283 + 284 + try { 285 + const { apps, errors } = await discoverTestableApps(); 286 + this.availableApps = apps; 287 + this.elements.appSelect.innerHTML = [ 288 + '<option value="">Select an app...</option>', 289 + ...apps.map((app) => `<option value="${this.escapeAttribute(app.value)}">${this.escapeHtml(app.name)}</option>`) 290 + ].join(''); 291 + this.elements.sessionMessage.textContent = errors.length 292 + ? `Loaded ${apps.length} app(s). ${errors.length} app metadata file(s) were skipped.` 293 + : `Loaded ${apps.length} testable app(s).`; 294 + if (errors.length) { 295 + console.warn('[UXET] Some testable apps were skipped:', errors); 296 + } 297 + } catch (error) { 298 + this.availableApps = []; 299 + this.elements.appSelect.innerHTML = '<option value="">No apps found</option>'; 300 + this.elements.sessionMessage.textContent = error.message || 'Failed to discover testable apps.'; 301 + console.error('[UXET] Testable app discovery failed:', error); 302 + } finally { 303 + this.elements.appSelect.disabled = false; 304 + this.elements.loadAppBtn.disabled = false; 305 + this.selectApp(this.elements.appSelect); 306 + this.syncDebugUi(); 307 + } 308 + } 309 + 310 + escapeHtml(value) { 311 + return String(value).replace(/[&<>"']/g, (char) => ({ 312 + '&': '&amp;', 313 + '<': '&lt;', 314 + '>': '&gt;', 315 + '"': '&quot;', 316 + "'": '&#39;' 317 + })[char]); 318 + } 319 + 320 + escapeAttribute(value) { 321 + return this.escapeHtml(value); 322 + } 323 + 277 324 getSelectedAppFromElement(selectElement) { 278 - const option = selectElement?.options?.[selectElement.selectedIndex]; 279 - if (!option?.value) { 325 + const value = selectElement?.value || ''; 326 + if (!value) { 280 327 return null; 281 328 } 282 329 283 - return { 284 - value: option.value, 285 - task: option.dataset.task, 286 - name: option.textContent.trim(), 287 - win: option.dataset.win || '' 288 - }; 330 + return this.availableApps.find((app) => app.value === value) || null; 289 331 } 290 332 291 333 syncSelectedAppUi() { ··· 457 499 this.session.startRecording(); 458 500 this.gazeTracker.setMode('recording'); 459 501 this.tracker.start(); 460 - this.winConditions.start(this.selectedApp.win, { 502 + this.winConditions.start({ 461 503 bridge: this.bridge, 462 504 session: this.session, 463 505 complete: (details) => this.finishTest(details)
+84
js/testableApps.js
··· 1 + const TESTABLE_APPS_ROOT = 'testable-apps/'; 2 + const APP_METADATA_FILE = 'app.json'; 3 + 4 + function normalizeDirectoryHref(href) { 5 + if (!href || href.startsWith('?') || href.startsWith('#')) { 6 + return null; 7 + } 8 + 9 + const rootUrl = new URL(TESTABLE_APPS_ROOT, window.location.href); 10 + const url = new URL(href, rootUrl.href); 11 + if (!url.href.startsWith(rootUrl.href) || !url.pathname.endsWith('/')) { 12 + return null; 13 + } 14 + 15 + const relativePath = url.href.slice(rootUrl.href.length); 16 + const directoryName = decodeURIComponent(relativePath.replace(/\/$/, '')); 17 + if (!directoryName || directoryName.includes('/')) { 18 + return null; 19 + } 20 + 21 + return directoryName; 22 + } 23 + 24 + function parseDirectoryListing(html) { 25 + const document = new DOMParser().parseFromString(html, 'text/html'); 26 + return Array.from(document.querySelectorAll('a[href]')) 27 + .map((link) => normalizeDirectoryHref(link.getAttribute('href'))) 28 + .filter(Boolean) 29 + .filter((value, index, values) => values.indexOf(value) === index) 30 + .sort((a, b) => a.localeCompare(b)); 31 + } 32 + 33 + function normalizeMetadata(directoryName, metadata) { 34 + const name = typeof metadata?.name === 'string' ? metadata.name.trim() : ''; 35 + const task = typeof metadata?.task === 'string' ? metadata.task.trim() : ''; 36 + if (!name || !task) { 37 + throw new Error(`${TESTABLE_APPS_ROOT}${directoryName}/${APP_METADATA_FILE} must include non-empty "name" and "task" strings.`); 38 + } 39 + 40 + return { 41 + name, 42 + task, 43 + value: `${TESTABLE_APPS_ROOT}${encodeURIComponent(directoryName)}/index.html` 44 + }; 45 + } 46 + 47 + async function fetchJson(url) { 48 + const response = await fetch(url, { cache: 'no-store' }); 49 + if (!response.ok) { 50 + throw new Error(`Failed to load ${url}: ${response.status}`); 51 + } 52 + return response.json(); 53 + } 54 + 55 + export async function discoverTestableApps() { 56 + const response = await fetch(TESTABLE_APPS_ROOT, { cache: 'no-store' }); 57 + if (!response.ok) { 58 + throw new Error(`Failed to inspect ${TESTABLE_APPS_ROOT}: ${response.status}`); 59 + } 60 + 61 + const directories = parseDirectoryListing(await response.text()); 62 + if (!directories.length) { 63 + throw new Error(`No app folders were found in ${TESTABLE_APPS_ROOT}.`); 64 + } 65 + 66 + const results = await Promise.allSettled(directories.map(async (directoryName) => { 67 + const metadata = await fetchJson(`${TESTABLE_APPS_ROOT}${encodeURIComponent(directoryName)}/${APP_METADATA_FILE}`); 68 + return normalizeMetadata(directoryName, metadata); 69 + })); 70 + 71 + const apps = results 72 + .filter((result) => result.status === 'fulfilled') 73 + .map((result) => result.value) 74 + .sort((a, b) => a.name.localeCompare(b.name)); 75 + const errors = results 76 + .filter((result) => result.status === 'rejected') 77 + .map((result) => result.reason?.message || 'Unknown app metadata error.'); 78 + 79 + if (!apps.length) { 80 + throw new Error(errors.join('\n') || `No valid ${APP_METADATA_FILE} files were found.`); 81 + } 82 + 83 + return { apps, errors }; 84 + }
+1 -4
js/winConditions.js
··· 33 33 this.activeEvaluator = null; 34 34 } 35 35 36 - start(spec, context) { 36 + start(context) { 37 37 this.stop(); 38 - if (spec && spec !== 'postMessage') { 39 - console.warn('[WinCondition] Only postMessage win conditions are supported. Ignoring:', spec); 40 - } 41 38 this.activeEvaluator = createPostMessageEvaluator(); 42 39 this.activeEvaluator.start(context); 43 40 }
+4
testable-apps/example-app/app.json
··· 1 + { 2 + "name": "Feedback Terminal", 3 + "task": "Fill out the feedback form with your details" 4 + }
+4
testable-apps/long-page-app/app.json
··· 1 + { 2 + "name": "Signal Intelligence Report", 3 + "task": "Review the comparison sections and subscribe at the bottom of the page" 4 + }
+4
testable-apps/shop-app/app.json
··· 1 + { 2 + "name": "THREAD Store", 3 + "task": "Find and purchase a blue t-shirt" 4 + }