Skip to content
This repository was archived by the owner on Oct 2, 2026. It is now read-only.
adobecomPublic archive

Repository files navigation

Getting Started

npm install && npm install --prefix server
npm run dev            # vite dev server, with hot reload
node server/index.js   # /lab backend on :4000

Deploying

Serve 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, /lab

npm 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.

Run Console

/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.

Datasets

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.

Baseline and trend

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 check

/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.page preview is compared with the branch preview.
  • 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-concierge files 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

/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).

Backend tests

node --test server/*.test.js

Milo releases

/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_TOKEN is optional because milo is public, but it avoids the low unauthenticated rate limit.
  • RELEASE_REPO (default adobecom/milo) and RELEASE_TITLE (default [Release] Stage to Main) override the target.

Visual diff for any repo

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}.

AI Judge

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.

What the model actually sees

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.

Ask agent

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.js

The 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.

Brand Concierge agent

💬 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.

Releases

Packages

Used by

Contributors

Languages