npm install && npm install --prefix server
npm run dev # vite dev server, with hot reload
node server/index.js # /lab backend on :4000Serve the built app, not the dev server:
npm install && npm install --prefix server
npm run build # emits dist/
node server/index.js & # /lab backend on :4000
npm run preview -- --host --port 80 # serves dist/ + proxies /api, /nala, /labnpm run dev on port 80 also works, but it ships unbundled source: every
module is a separate request and nothing is minified or cached, which is the
single biggest cause of slow page loads. vite preview serves the same
hashed, minified, long-cacheable bundles the build produces, and
vite.config.js gives preview the same /api, /nala and /lab proxies
as dev (Vite keeps server.proxy and preview.proxy separate, so they must
be shared explicitly — they are).
The backend also serves /lab/thumb, which downscales the 1-2 MB full-page
screenshots into the few-KB previews the viewer's snapshot list renders, and
caches them under server/.thumb-cache/. That directory is disposable; delete
it to reclaim disk.
/console dispatches and live-tracks the screenshot-diff workflows. Four run
kinds: Viewport diff (a dataset), ⚡ Quick run (pasted URLs),
🎨 Figma compare (one page region vs one Figma frame) and
Real iOS · Simulator. It needs the server/ backend running — see
LAB.md for setup, the workflow dispatch fields and the results
contract.
Results are viewed at /imagediff/<site> (latest) or
/imagediff/<site>?run=<runId> (one specific run). Published results are pruned
upstream: dataset runs keep the newest 3 for up to 7 days, and quick / Figma runs
last 24 hours.
Every dataset is a sheet at
https://milo.adobe.com/drafts/nala/screenshotdiff/data/<name>.json. When you add
one, the server checks that the sheet exists (GET /lab/datasets/<name>). The
console then previews it: page count, sample rows, and invalid or ignored rows.
The server records each dataset's last finished run time (GET /lab/durations,
stored in server/.run-durations.json), and the dataset dropdown shows it, for
example milo · last 18m. On startup it backfills from recent runs.
Big datasets are split across more runners. Each viewport's pages can be split
into 1-4 shards (the fork workflow's shards input, so jobs are named
capture-chrome-1, capture-chrome-2, and so on). Auto, the default, uses
about 12 pages per job, capped by the runners that other in-flight nala-auto runs
are not using (SCREEN_RUNNERS, default 11). GET /lab/shard-plan?site= shows
the estimate. The console's Parallel chips override it.
Optional sheet columns, read by the fork's load-data.js, help with flaky pages:
| Column | Example | Effect |
|---|---|---|
waitStrategy |
footer / scroll |
Wait mode for this page. footer is the default. scroll adds a slow scroll plus networkidle, for lazy-loaded sections. |
mask |
.carousel; #ad-slot |
CSS selectors, separated by ; or newlines, that are painted over in the screenshot (for dynamic content). |
ignore |
yes / x / 1 |
Skip the row without deleting it. |
After each live dataset run, the server scores every page (server/visualHistory.js).
It compares each page against the accepted baseline, or the previous run if no baseline has been accepted. Each page is marked changed, new, missing or same. A page is also marked flaky when its diff % keeps jumping across the last 10 runs.
Where this shows up:
/imagediff/<site>shows a summary bar with a trend sparkline. Accept run as baseline applies to every page; ✓ Accept applies to one page. Changed since baseline filters the page list.- The console's recent runs show the same counts as badges.
History is stored in server/.history/<site>/. Override the location with HISTORY_DIR. Older runs are backfilled at startup; set HISTORY_BACKFILL=0 to skip that.
API:
GET /lab/history/run?site=&run=GET /lab/history/trend?site=POST /lab/history/baseline {site, runId, keys?}or{site, reset: true}
/pr takes a PR link, picks the checks that cover it, starts them and rolls them up into one verdict (server/prCheck.js).
- PR test URLs come from the PR description. Before/After pairs are compared directly.
- For Milo PRs, any other URL is compared with and without
?milolibs=<branch>; branches from forks use<branch>--milo--<owner>. - For consumer-repo PRs, the
main--repo--owner.aem.pagepreview is compared with the branch preview.
- For Milo PRs, any other URL is compared with and without
- Changed blocks: dataset pages whose key or path matches a
blocks/<name>/folder touched by the PR. There are at most 5 pages per block. Blocks with no page are reported as not covered. - Full datasets: offered only for Milo PRs. They are recommended when shared (non-block) code changes and run with the PR's
milolibs. - Brand Concierge: offered when
brand-conciergefiles change and the PR has a test URL.
Quick runs use the chrome and iphone viewports. A quick-run page is flagged when its A/B diff is over 1% or its height moves more than 40px. A dataset page is flagged when it changed vs. the baseline and is not flaky.
Checks are kept in server/.pr-checks.json (override with PR_CHECKS_FILE); the newest 50 are kept.
API:
curl -X POST https://nala-auto.corp.adobe.com/lab/pr-checks/plan -H 'content-type: application/json' \
-d '{"url":"https://github.com/adobecom/milo/pull/1234"}'
# Start: omitted keys use the plan's recommendation
curl -X POST https://nala-auto.corp.adobe.com/lab/pr-checks -H 'content-type: application/json' \
-d '{"url":"adobecom/milo#1234","testUrls":true,"blocks":true,"bc":false,"datasets":["milo"]}'
curl https://nala-auto.corp.adobe.com/lab/pr-checks # recent checks
curl https://nala-auto.corp.adobe.com/lab/pr-checks/<id> # summary + verdict/runners (backed by GET /lab/runners) shows the self-hosted Mac mini pool:
which runner is busy and on what job, each runner's last job, jobs still waiting
for a runner, and recent jobs from the last 24 hours. It refreshes every 15s;
the backend caches for 20s and keeps jobs of completed runs in memory.
Online/idle/offline status for every registered runner comes from the GitHub
runners API, which needs the backend's GITHUB_TOKEN to have Administration:
Read (fine-grained PAT) or admin access (classic PAT) on the workflow repo.
Without it, the page still works from job history: it lists runners that ran a
job in the last 14 days (with "last seen"), but can't show online vs offline or
runners that have been silent longer than that.
Some Mac minis are registered under their hostname (sj1010122072235) and carry
their MacNodeXX name as a runner label. With runners-API access the page shows
that label as the name (hostname underneath). Otherwise set the mapping in the
backend env (RUNNER_ALIASES=host=Name,...); the current four sj hosts are built in (226→MacNode03, 231→MacNode05, 234→MacNode07, 235→MacNode08).
node --test server/*.test.js/releases monitors the [Release] Stage to Main PRs in adobecom/milo. It shows:
- the open release PR, with:
- team SOT sign-off labels (signed vs. still missing);
- the CI check rollup, with links to failing or running checks;
- the PRs bundled in the release body;
- the last few merged releases.
Data comes from GET /lab/releases (server/releasePrs.js). Responses are cached for 2 minutes; ?refresh=1 bypasses the cache.
GITHUB_TOKENis optional because milo is public, but it avoids the low unauthenticated rate limit.RELEASE_REPO(defaultadobecom/milo) andRELEASE_TITLE(default[Release] Stage to Main) override the target.
Each repo is mapped to the datasets that cover it. For example, adobecom/milo maps to milo. You can edit the mapping on /releases, or with PUT /lab/repo-datasets {repo, datasets}. Overrides are saved in server/.repo-datasets.json.
Run visual diff starts one live run per mapped dataset. Other repos can trigger the same thing from CI:
curl -X POST https://nala-auto.corp.adobe.com/lab/repo-runs \
-H 'Content-Type: application/json' \
-d '{"repo":"adobecom/da-bacom","milolibs":"?milolibs=stage"}'The response is {repo, runs:[{runId, site, resultsUrl}], errors}.
The "🤖 AI Judge" button in the screenshot-diff viewer asks a vision model whether a diff is a real regression or harmless noise (carousel frames, timestamps, rotating promos, mid-animation states).
If the Ask agent's AI_FOUNDRY_API_KEY is set, the judge uses it with the AI
Foundry gateway and the vision-capable aifoundry/Qwen/Qwen-latest (same model as the Ask agent)
(override with AI_JUDGE_MODEL) — nothing else to configure. Otherwise it calls
whatever OpenAI- or Anthropic-compatible endpoint you configure:
| Variable | Required | Notes |
|---|---|---|
AI_JUDGE_API_KEY |
no* | *Required unless AI_FOUNDRY_API_KEY is set. Its presence is what "configured" means. Endpoints without real auth (e.g. a self-hosted proxy) still need a non-empty placeholder. |
AI_JUDGE_PROVIDER |
no | openai (default) or anthropic — selects the wire format. |
AI_JUDGE_BASE_URL |
no | Override the endpoint, e.g. Azure OpenAI or an OpenAI-compatible proxy. |
AI_JUDGE_MODEL |
no | Defaults to gpt-4o-mini / claude-3-5-haiku-latest. Must be vision-capable. |
Restart the backend after setting these. Unconfigured, /lab/judge returns a
friendly explanation rather than an error, and the button still renders.
Full-page captures can run to ~7000px tall, and vision models downscale them far enough to erase the copy and layout details the judge is meant to catch. So the browser crops full-width strips around the detected diff hotspots and sends only those — for baseline, new, and the diff overlay — each labelled with its vertical pixel range. Crops keep native width up to 2000px.
When no hotspots are available (a MATCH snapshot, or a failed diff scan) the server falls back to fetching the whole pages from S3.
Screenshots are fetched from internal storage and forwarded to whichever endpoint you configure. Confirm that egress is acceptable before pointing this at a third-party or self-hosted model.
The floating ✦ Ask button (bottom right, on every page) opens a chat panel that answers questions about this console's own data — recent runs, Brand Concierge monitor checks and screenshot-diff results.
It is read-only: the model can call four tools (list_runs,
list_datasets, get_dataset_results, get_bc_monitor) and nothing else. It
cannot start, stop or change a run.
The default target is Adobe's internal AI Foundry gateway
(https://aifoundry-preview.corp.adobe.com), which is a plain
OpenAI-compatible LiteLLM proxy. Generate a personal virtual key (sk-…)
there, then configure the backend:
| Variable | Required | Notes |
|---|---|---|
AI_FOUNDRY_API_KEY |
yes | Your AI Foundry virtual key. Its presence is what "configured" means. |
AI_FOUNDRY_BASE_URL |
no | Defaults to https://apigw.infra.adobe.net/ehl/api/v1/ehl/v1. Any OpenAI-compatible base URL works. |
AI_FOUNDRY_MODEL |
no | Defaults to aifoundry/Qwen/Qwen-latest. hosted_vllm/google/gemma-4-26B-A4B-it is cheaper. |
AI_FOUNDRY_API_KEY=sk-... node server/index.jsThe key lives only on the backend — the browser only ever talks to /lab/ask.
Unconfigured, that route returns a friendly explanation (HTTP 200) rather than
an error, and the panel still renders and says what to set.
Questions and the tool results they pull in (run ids, dataset names, BC check outcomes) are sent to the configured model. Keep this pointed at an internal gateway.
💬 BC workflow (/bc-agent) accepts any HTTP(S) page containing Brand
Concierge and dispatches brand-concierge-agent.yml through the same GitHub
Actions + self-hosted Mac mini path as Screenshot Diff. The workflow runs the
standard conversation health check and uploads a shareable Markdown/JSON
summary, full HTML transcript and screenshots as a seven-day artifact. See
LAB.md.