Native Shell
Native Shell:AI 生成的 UI,在你的应用中原生呈现,覆盖每个平台。iOS、Android、macOS、Windows 与 Linux 上的应用、GPUI 应用与网页在运行时加载已签名、沙箱隔离的 TSX 模块并绘制 th
AI 翻译
制作者的介绍仅有上面的一句话。 更多详情请阅读仓库 README。 仓库 README ↗
正在加载完整 README。
查看完整 README ↗Elastic screenshot comparison for UI change detection. Compare two screenshots, or two versions of a page by URL, and get a report of what changed even when the layout zoomed, a section collapsed, or the viewport changed size. Every difference is named by the DOM locator behind it, not just drawn.
<elastishot-viewer> (slider, flip, blink, overlay, diff), report.json and JUnit XML.snapshot / run / approve keep a baseline per target and viewport; the CLI exits non-zero when a deployment drifted.pr serves the base commit next to your working tree and compares the two in one run, on one machine with one browser, so fonts and rendering match and nothing is committed. In GitHub Actions it writes a table to the job summary.Elastishot is a Node 20+ library and CLI written in TypeScript, MIT licensed, with no telemetry: the CLI contacts only the URLs you pass it. The install is about 16 MB, nearly all of it the opencv.js WebAssembly build; there is no native compile step.
npm install -D elastishot
npm install -D playwright && npx playwright install chromium # only needed for page URLs
npx elastishot compare https://staging.example.com/ https://example.com/ --full-page
npx elastishot compare before.png after.png
open .elastishot/runs/<run-id>/index.html
npx elastishot pr # this branch against the one it left, both served here (needs a config)
The first command captures both pages, compares them and writes a run folder. The last line of the output is the path of the summary page. Exit code 1 means differences were found.
elastishot/
├── bin/elastishot.js CLI entry (imports dist/cli/main.js)
├── src/
│ ├── index.ts Public entry: types, geometry, image helpers, compare(), comparePair()
│ ├── pipeline.ts comparePair(): engine + locators + verdict; Plugin and Reporter contracts
│ ├── core/ Isomorphic: types, geometry, image utilities, options, thresholds, engine seam
│ ├── engine/ opencv.js pipeline: preprocess, global align, structural align, diff, classify, verdict
│ │ ├── cv/ Loader, Mat lifetime scope, typed subset of the opencv.js API
│ │ ├── pure/ No-WASM algorithms: sequence alignment, colour distance, box merging, scoring
│ │ └── stages/ One file per stage; every stage is replaceable
│ ├── locators/ Regions x element maps -> changed locators
│ ├── capture/ Playwright capture with the in-page element collector
│ ├── report/ report.json schema, summary page, pair page, JUnit
│ ├── viewer/ <elastishot-viewer> custom element (bundled by scripts/build-viewer.mjs)
│ ├── node/ Files, URLs, snapshot folders, config, run folders, artifacts, the Node façade
│ └── cli/ Argument parsing and the five commands
├── scripts/ build-viewer, make-fixtures, check-layering, check-package, changelog-section, spike-opencv
├── test/ Engine, capture, viewer and workflow tests; synthetic fixtures; fixture site v1/v2
├── acceptance/ Gherkin features A-G with matching *.acceptance.test.js and trace.js
├── docs/ ATDD.md, ELEMENT-MAP.md, EMBEDDING.md, ROADMAP.md, RELEASING.md
├── examples/ui-lab/ A localhost page in ten builds with ground truth; scores every run
├── site/ The landing page builder (site/dist is generated)
└── elastishot.config.example.js Named targets and viewports for the run workflow
| Command | What it does |
|---|---|
elastishot compare <baseline> <candidate> | Each side is a baseline folder, a PNG/JPEG path, an image URL or a page URL. Writes a run folder. |
elastishot snapshot <url> | Captures a page (PNG + element map + meta) as a baseline folder. |
elastishot run [target...] | Captures and compares every target x viewport from elastishot.config.*. |
elastishot pr [target...] | Compares the working tree with a base commit. Both are served and captured in this run, so no baselines are kept. Writes a run folder and a Markdown summary. |
elastishot approve [target[/viewport]|runDir] | Promotes a run's candidate to the baseline; --all takes every failed or new pair. |
elastishot report <runDir> | Re-renders index.html and junit.xml from a run's report.json. |
Options:
--out <dir>, --config <file>, --name <name>, --threshold <0..1>, --fail-on added,removed,changed,moved|none, --json, --quiet, --help, --version.--viewport WxH[@dpr]|<name>, --full-page, --wait-for <selector|ms>, --hide <selector> (repeatable), --no-map.--junit, --junit-file <file>, --single-file.--no-config (compare); --base-url <url>, --update and --force (run); --base <ref> and --keep (pr); --all (approve).A flag a command does not use is a usage error. Run elastishot --help for the full text.
| npm script | What it does |
|---|---|
npm test | Unit tests (src/**/*.test.ts), no browser, no WASM |
npm run test:engine | Engine tests against opencv.js with synthetic pages, plus a timing and a leak check |
npm run test:browser | Capture, viewer and workflow tests in Chromium |
npm run test:acceptance | Builds, then drives the CLI as a subprocess through the Gherkin scenarios |
npm run test:trace | Every scenario in acceptance/features must have a test |
npm run test:all | All of the above plus typecheck and check:layering |
npm run build | Bundles the viewer, then compiles src/ to dist/ |
baseline (png | url) ─┐ ┌─ report.json
├─► capture ─► PNG + element map ─┐ ├─ index.html (one card per pair)
candidate (png | url) ┘ │ ├─ pairs/<id>/report.html (viewer, regions, locators)
▼ ├─ junit.xml
preprocess ─► global align ─► structural align ─► diff ─► classify ─► verdict ─► locators ─┤
(working (ORB + RANSAC (strips + sequence (YIQ (moved = (score, (regions x └─ plugins / reporters
scale) similarity or alignment: matched, delta, template pass/fail) element maps)
affine, fall- inserted, deleted shift match)
backs) bands) tolerant)
removed regions, inserted runs added regions, matched runs go to the differ with their offset refined to the pixel.moved region.The UI lab in examples/ui-lab renders one page in ten builds and records what each build changes (43 elements: added, changed, moved, expanded, re-drawn, one column of a two-column hero that grew while the other stayed, and a card whose two columns grew by different amounts under a picture that was redrawn in place). Every run is scored against that ground truth. On the current engine:
| Measure | Result |
|---|---|
| planted changes found, named by locator or covered by a region | 43 of 43 |
| regions on elements nobody changed, score 0.2 or higher | 1 |
The one remaining region is a stat card whose sparkline was redrawn at a new width after the cards spread out: a real difference, on an element the build did not list. Reproduce it with node examples/ui-lab/run-lab.mjs --no-video; the report in examples/ui-lab/report/index.html lists every planted change with its verdict and every noise region with its box.
Projects by Elastishot's author that use it on every change. Their configs and real reports show how each one gets both sides into the same state:
elastishot pr and the GitHub Action on every pull request, 20 pairs. Pull request #18 is a real run.[data-testid="report-link"], and the one digit that changed in "Saved by Whetprompt 0.5.0", a 6 × 9 pixel region.[data-testid="me-simple-mode"], as one region and compared the rows below it in their new place.cp elastishot.config.example.js elastishot.config.js # edit targets and viewports
npx elastishot run --update # first time: writes baselines/<target>/<viewport>/
npx elastishot run # later: compares every target, exit 1 on drift
npx elastishot approve home # accept the new look of one target (or --all)
baselines/<target>/<viewport>/ baseline.png, baseline.map.json, meta.json
.elastishot/runs/<run-id>/ report.json, index.html, junit.xml, pairs/<target>--<viewport>/...
.elastishot/runs/latest the newest run folder (approve uses it by default)
A GitHub Actions job:
- run: npm ci && npx playwright install --with-deps chromium
- run: npx elastishot run --junit
- uses: actions/upload-artifact@v7
if: always()
with: { name: elastishot, path: .elastishot/runs }
run --update --force also replaces baselines that differ: use it when a whole redesign is intended. Target URLs can be paths relative to baseUrl in the config, and run --base-url http://127.0.0.1:4173/ points the same targets at a local build.
Baselines kept in a repository go stale, and baselines captured on one machine differ from captures on another (fonts, the browser build). elastishot pr avoids both:
// elastishot.config.mjs
export default {
viewports: [{ name: 'desktop', width: 1280, height: 800 }, { name: 'mobile', width: 390, height: 844 }],
capture: { fullPage: true },
targets: [
{ name: 'home', url: '/' },
{ name: 'pricing', url: '/pricing' },
],
// A static site needs no pr block: the config folder itself is served.
pr: {
install: 'npm ci', // in the base worktree only
build: 'npm run build', // in both trees
serve: { dir: 'dist', spa: true }, // or a command: { command: 'npm run preview -- --port {port} --strictPort' }
},
}
npx elastishot pr # this branch against where it left origin/HEAD (or main, or master)
npx elastishot pr --base v2.3.0 # against a tag, a branch or a commit
How each side runs:
baseUrl, when set, keeps its path.new with the reason; it is not an error.junit.xml with --junit, and summary.md. In GitHub Actions the same table is appended to the job summary.baselines/, and the worktree is removed afterwards. Use --keep to inspect it.{port}, or on $PORT. A server that falls back to another port when its own is busy (Vite without --strictPort) fails the run rather than being compared.In a GitHub pull request job the base is the base side of the merge commit the checkout made, so the check needs fetch-depth: 2. The elastishot-action wraps the steps below, upload and summary link included (uses: Osman19702/[email protected]); written out, they are:
on: pull_request
jobs:
visual:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with: { fetch-depth: 2 }
- uses: actions/setup-node@v7
with: { node-version: 22, cache: npm }
- run: npm ci && npx playwright install --with-deps chromium
- run: npx elastishot pr --junit --out .elastishot/pr
- uses: actions/upload-artifact@v7
if: always()
with: { name: elastishot, path: .elastishot/pr }
Use pull_request, not pull_request_target: the second would build code from a fork with access to the repository's secrets.
import { compare, comparePair } from 'elastishot'
import { capture } from 'elastishot/capture'
import { readImageFile, createElastishot } from 'elastishot/node'
// images only
const result = await compare(await readImageFile('a.png'), await readImageFile('b.png'), { workingWidth: 1024 })
console.log(result.summary.similarity, result.regions)
// pages, with named locators
const [v1, v2] = await Promise.all([capture('https://a.test/', { fullPage: true }), capture('https://b.test/', { fullPage: true })])
const { result: r, locators, pass } = await comparePair(v1, v2, { threshold: 0.98 })
console.log(pass, locators.changedLocators.map((l) => `${l.kinds.join('/')} ${l.name} ${l.locator}`))
// the whole workflow, with a custom reporter and a hook
const app = await createElastishot({
reporters: [{ name: 'slack', async report(report) { /* post report.totals */ return [] } }],
plugins: [{ name: 'notes', onCompared({ pair }) { if (pair.status === 'failed') pair.failReasons.push('review required') } }],
})
const run = await app.run()
await app.close()
process.exitCode = run.exitCode
Subpath exports: elastishot (isomorphic core), elastishot/engine (create an engine with your own stages, plugins or a pre-loaded cv), elastishot/locators, elastishot/report, elastishot/viewer (registers the element), elastishot/capture and elastishot/node (Node only). See docs/EMBEDDING.md for browser and framework use.
index.html: one card per pair with status, similarity, counts, thumbnails and the top changed locators; filters by status.pairs/<id>/report.html: the viewer with mode toolbar, side-by-side images, a region table linked to the viewer, changed locators, warnings and details.report.json: everything the pages show, schema elastishot.report/1 (see docs/ELEMENT-MAP.md).junit.xml: one testcase per pair; failures carry the reasons and the changed locators.Images are written as files next to the pages, thumbnails are inlined so index.html is portable on its own. --single-file inlines every image for a report that must be a single attachment; expect a few MB per pair.
| Code | Meaning |
|---|---|
| 0 | Every pair at or above the threshold with no region of a --fail-on kind |
| 1 | Differences found, a target without a baseline (unless --update), or, for pr, a page the base does not have |
| 2 | Usage error, or a runtime error (browser, engine, unreadable input); for pr also git, install, build or server failures, and a base that captured nothing |
npm install -D playwright && npx playwright install chromium. Playwright is an optional peer dependency; image-only use does not need it.hide and mask styles are applied as a constructed stylesheet, which style-src does not govern, so pages without 'unsafe-inline' capture unchanged.capture: { colorScheme: 'dark' } (emulated prefers-color-scheme) and another 'light'; each becomes its own baseline.--hide .cookie-banner, --wait-for "[data-testid=ready]", or capture.mask in the config; animations and transitions are already disabled during capture. A clock or a live counter can be left out of the comparison with ignore: ['[data-testid=clock]'], which follows the element wherever it lands. Pin dates with capture.clock.CAPTURE_REDIRECTED when its page ended at another path or origin, such as a sign-in or onboarding gate. Seed the state with capture.storageState or capture.setup. Give the target an expect selector only the real page has, so a redirected capture fails instead of becoming a baseline.diff.threshold (default 0.1) in the config compare section, or lower diff.maxRegions. diff.antialiasTolerance defaults to auto: 1 px when the candidate had to be resampled (zoom, rotation, fallback alignment) and 0 on same-scale pages, so a changed digit in small text is reported. Set it to a number to force one behaviour.CAPTURE_ERROR_PAGE warning when the response status was 4xx/5xx, the title looks like an error or redirect notice, or fewer than five elements were found. Check the URL, the wait settings and authentication before trusting the score.STRUCT_WEAK_MATCH and warnings in the pair page; long identical lists and pages without texture are the usual causes.createEngine({ cv }).Pull requests are welcome under the Developer Certificate of Origin; see CONTRIBUTING.md. Vulnerabilities go through SECURITY.md, not the issue tracker. Releases are cut as described in docs/RELEASING.md and recorded in CHANGELOG.md; the roadmap says what comes next.
failOnALIGN_*workingWidth (default 1280), below which whole-pixel shifts become fractional and the antialiasing tolerance switches on. --max-old-space-size helps Node with the decoded PNGs.elastishot/capture and elastishot/node type declarations reference Playwright's types. Install playwright (it is the optional peer dependency anyway) or set skipLibCheck in your tsconfig..elastishot/runs/latest is a text file, not a symlink.