-
Notifications
You must be signed in to change notification settings - Fork 0
feat: load test results from uploaded archive files #165
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
eboudrant
wants to merge
1
commit into
main
Choose a base branch
from
feat/scan-from-files
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,68 @@ | ||
| # feat: load test results from uploaded archive files (multi-file, conflict-aware) | ||
|
|
||
| **Date:** 2026-07-07 | ||
| **Type:** Feature | ||
|
|
||
| ## Intent | ||
| Until now the only way to overlay a CI run's screenshot-test `build/` outputs onto a | ||
| project was **Scan from URL** (download one `.tar`/`.tar.gz`). Users who have result | ||
| archives on disk, or who produce several sharded CI archives, had no path in. This adds a | ||
| **file picker** next to the URL input that accepts **multiple** `.zip`/`.tar`/`.tar.gz` | ||
| files, **merges** them into a single overlay + scan, and — when two archives contain the | ||
| same file with **different content** — **aborts** with an error dialog listing the | ||
| conflicts. Identical-content duplicates are merged silently. | ||
|
|
||
| ### Prompts summary | ||
| 1. "For the load test result from zip file, we currently propose a URL input, I also want a | ||
| file picker that supports multiple files; if a duplicate file is found in multiple zip | ||
| files it should show an error dialog if the duplicate file name's content is different." | ||
| 2. Confirmed: accept zip + tar/tar.gz; merge into one scan; abort-all on any conflict. | ||
|
|
||
| ## Changes | ||
|
|
||
| ### `src/localArchive.js` (new) | ||
| - Sibling of `remoteFetch.js` for local uploads. `sniffFormat` (magic bytes), `extractArchive` | ||
| (`unzip`/`tar`), `collectBuildMembers` (walk + `isUnsafeMember` guard + symlink skip), | ||
| `hashFile` (sha256), `mergeStages` (cross-archive conflict detection), `overlayBuildDirs` | ||
| (`fs.cpSync` of `<module>/build` subtrees only), and temp/stage cleanup. Reuses | ||
| `remoteFetch`'s `isBuildMember`/`isUnsafeMember`/`moduleRootOf`/`checkCompat`/`MAX_DOWNLOAD_BYTES`. | ||
|
|
||
| ### `src/remoteFetch.js` | ||
| - Export `byteCapTransform` so the raw-upload route can reuse the streaming byte cap. | ||
|
|
||
| ### `src/scanJobs.js` | ||
| - Upload registry (`registerUpload`/`takeUploads`) with TTL reaping of unconsumed uploads. | ||
| - `startScanFromUploads` → `runUploads` pipeline: extract all → merge/conflict-detect → | ||
| abort on conflict (project untouched) → `checkCompat` → park `needs_confirmation` or | ||
| `doOverlayAndScan` → `runScan`. | ||
| - `finishJob` now frees stage dirs + raw uploads and carries a `conflicts` payload; `getJob` | ||
| surfaces `conflicts`. `confirmScanFromUrl` generalized to kind-aware `confirmScanJob`. | ||
|
|
||
| ### `src/handler.js` | ||
| - `POST /api/uploads` (raw stream → temp file, byte-capped, filename/extension validated) and | ||
| `POST /api/projects/:id/scan-from-uploads` (`{uploadIds}` → job). Confirm route now calls | ||
| `confirmScanJob`. | ||
|
|
||
| ### `static/js/home.js`, `static/css/app.css` | ||
| - File picker added **inside the existing hidden Scan-from-URL form** (keeps project-card | ||
| screenshot baselines unchanged). `_scanFromFiles` uploads each file then starts the merge | ||
| scan; `_showConflictDialog` lists conflicting paths on a conflict abort. | ||
|
|
||
| ### Tests & docs | ||
| - `tests/node/localArchive.test.js` (11 cases: pure helpers + e2e merge / identical-dup / | ||
| conflict-abort-project-untouched / zip+tar / needs_confirmation / size-cap 413 / validation). | ||
| - `docs/index.html` and `.claude/rules/scanning.md` document the upload + conflict behavior. | ||
|
|
||
| ## Files modified | ||
|
|
||
| | File | Change | | ||
| |------|--------| | ||
| | `src/localArchive.js` | New module: extract/merge/conflict-detect/overlay uploaded archives | | ||
| | `src/remoteFetch.js` | Export `byteCapTransform` | | ||
| | `src/scanJobs.js` | Upload registry, `startScanFromUploads`/`runUploads`/`doOverlayAndScan`, kind-aware `confirmScanJob`/`finishJob`, `getJob` conflicts | | ||
| | `src/handler.js` | `POST /api/uploads`, `POST /api/projects/:id/scan-from-uploads`, confirm route rename | | ||
| | `static/js/home.js` | File picker in the URL form, `_scanFromFiles`, conflict dialog | | ||
| | `static/css/app.css` | `.url-form-or` divider | | ||
| | `tests/node/localArchive.test.js` | New test suite | | ||
| | `docs/index.html` | Document uploaded-archive workflow | | ||
| | `.claude/rules/scanning.md` | Document URL-vs-uploads overlay + conflict rule | |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,190 @@ | ||
| /** | ||
| * Merge locally-uploaded screenshot-test result archives and overlay their | ||
| * build outputs onto a project directory. | ||
| * | ||
| * This is the file-upload sibling of `remoteFetch.js` (scan-from-URL). Where | ||
| * that module downloads a single `.tar` and extracts selected `build/` dirs | ||
| * straight out of the tar, this one accepts *several* archives (`.zip` or | ||
| * `.tar`/`.tar.gz`), extracts each fully into its own staging dir, and then: | ||
| * | ||
| * 1. collects the safe `build/` members of every stage, | ||
| * 2. hashes them to detect cross-archive conflicts (same path, different | ||
| * bytes) — which abort the whole operation, and | ||
| * 3. copies the `<module>/build` subtrees into the project (identical | ||
| * duplicates across archives are harmless overwrites). | ||
| * | ||
| * Full extraction (rather than tar member-selection) is deliberate: we need | ||
| * file contents on disk to hash for conflict detection, it unifies zip + tar | ||
| * handling behind one pipeline, and it sidesteps the `[bracket]` glob problem | ||
| * that Paparazzi parameterized snapshot names cause with `tar -T` member lists. | ||
| * | ||
| * No new npm deps: we shell out to the system `unzip` (zip) and `tar` (tar, | ||
| * gzip auto-detected). Both are present on macOS and Linux CI runners. | ||
| */ | ||
|
|
||
| 'use strict'; | ||
|
|
||
| const fs = require('fs'); | ||
| const path = require('path'); | ||
| const crypto = require('crypto'); | ||
| const { spawnSync } = require('child_process'); | ||
| const remoteFetch = require('./remoteFetch'); | ||
|
|
||
| // Same 2 GB ceiling as a scan-from-url download, applied per uploaded file. | ||
| const MAX_UPLOAD_BYTES = remoteFetch.MAX_DOWNLOAD_BYTES; | ||
|
|
||
| // Cap on how many archives a single merge can combine. Well past any real | ||
| // sharded-CI run; a backstop against a pathological request. | ||
| const MAX_UPLOAD_FILES = 32; | ||
|
|
||
| const SPAWN_OPTS = { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }; | ||
|
|
||
| // Detect the archive format from its magic bytes rather than the filename. | ||
| // Zip local-file/central-dir/end-of-central-dir headers all start `PK`. | ||
| // Everything else is treated as tar (system `tar` auto-detects gzip, so plain | ||
| // `.tar` and `.tar.gz` both extract; a genuinely bad file surfaces as a real | ||
| // `tar -xf` error rather than being guessed here). | ||
| function sniffFormat(filePath) { | ||
| const fd = fs.openSync(filePath, 'r'); | ||
| try { | ||
| const buf = Buffer.alloc(4); | ||
| const n = fs.readSync(fd, buf, 0, 4, 0); | ||
| if (n >= 2 && buf[0] === 0x50 && buf[1] === 0x4b) return 'zip'; // "PK" | ||
| return 'tar'; | ||
| } finally { | ||
| fs.closeSync(fd); | ||
| } | ||
| } | ||
|
|
||
| // Extract one archive fully into stageDir (created if needed). Throws with the | ||
| // tail of the extractor's stderr on failure. | ||
| function extractArchive(filePath, stageDir) { | ||
| fs.mkdirSync(stageDir, { recursive: true }); | ||
| const format = sniffFormat(filePath); | ||
| const r = format === 'zip' | ||
| ? spawnSync('unzip', ['-o', '-q', filePath, '-d', stageDir], SPAWN_OPTS) | ||
| : spawnSync('tar', ['-xf', filePath, '-C', stageDir], SPAWN_OPTS); | ||
| if (r.status !== 0) { | ||
| const detail = (r.stderr || '').slice(-300) || `${format} extract failed`; | ||
| throw new Error(`could not extract archive: ${detail}`); | ||
| } | ||
| } | ||
|
|
||
| // Walk a staged archive and return its safe `build/` members as | ||
| // { relPath (POSIX), absPath }. Directories and symlinks are skipped; | ||
| // a member whose path escapes the stage root throws (defence-in-depth against | ||
| // zip-slip on top of the extractor's own guards). | ||
| function collectBuildMembers(stageDir) { | ||
| const members = []; | ||
| const walk = (dir) => { | ||
| for (const dirent of fs.readdirSync(dir, { withFileTypes: true })) { | ||
| const abs = path.join(dir, dirent.name); | ||
| if (dirent.isSymbolicLink()) continue; | ||
| if (dirent.isDirectory()) { walk(abs); continue; } | ||
| if (!dirent.isFile()) continue; | ||
| const relPath = path.relative(stageDir, abs).split(path.sep).join('/'); | ||
| if (!remoteFetch.isBuildMember(relPath)) continue; | ||
| if (remoteFetch.isUnsafeMember(relPath)) { | ||
| throw new Error(`unsafe path in archive: ${relPath}`); | ||
| } | ||
| members.push({ relPath, absPath: abs }); | ||
| } | ||
| }; | ||
| walk(stageDir); | ||
| return members; | ||
| } | ||
|
|
||
| function hashFile(absPath) { | ||
| return crypto.createHash('sha256').update(fs.readFileSync(absPath)).digest('hex'); | ||
| } | ||
|
|
||
| // Merge the build members of several staged archives, detecting conflicts. | ||
| // `stages` is [{ name, dir }] (name = original filename, for reporting). | ||
| // | ||
| // Returns { members, moduleRoots, conflicts }: | ||
| // - members: one winning { relPath, absPath } per unique path. | ||
| // - moduleRoots: distinct module roots across all members (sorted). | ||
| // - conflicts: [{ path, archives }] — a path present in >1 archive with | ||
| // differing bytes. Empty when the merge is clean. Identical-content | ||
| // duplicates are merged silently (no conflict). | ||
| function mergeStages(stages) { | ||
| const seen = new Map(); // relPath -> { hash, absPath, archives:Set } | ||
| const conflicts = new Map(); // relPath -> Set of archive names | ||
| for (const stage of stages) { | ||
| for (const { relPath, absPath } of collectBuildMembers(stage.dir)) { | ||
| const prior = seen.get(relPath); | ||
| if (!prior) { | ||
| seen.set(relPath, { hash: hashFile(absPath), absPath, archives: new Set([stage.name]) }); | ||
| continue; | ||
| } | ||
| const hash = hashFile(absPath); | ||
| if (hash === prior.hash) { | ||
| prior.archives.add(stage.name); // identical duplicate — fine | ||
| continue; | ||
| } | ||
| let names = conflicts.get(relPath); | ||
| if (!names) { names = new Set(prior.archives); conflicts.set(relPath, names); } | ||
| names.add(stage.name); | ||
| } | ||
| } | ||
| const members = [...seen.entries()].map(([relPath, v]) => ({ relPath, absPath: v.absPath })); | ||
| const roots = new Set(members.map(m => remoteFetch.moduleRootOf(m.relPath))); | ||
| return { | ||
| members, | ||
| moduleRoots: [...roots].sort(), | ||
| conflicts: [...conflicts.entries()] | ||
| .map(([p, names]) => ({ path: p, archives: [...names].sort() })) | ||
| .sort((a, b) => a.path.localeCompare(b.path)), | ||
| }; | ||
| } | ||
|
|
||
| // Copy the `<moduleRoot>/build` subtrees from every stage into projectRoot. | ||
| // Only `build/` dirs are ever written, so source and goldens are never | ||
| // clobbered. When no conflict was detected, cross-stage overwrites of a shared | ||
| // path are byte-identical and therefore safe. Returns the number of build dirs | ||
| // copied. | ||
| function overlayBuildDirs(stages, moduleRoots, projectRoot) { | ||
| let copied = 0; | ||
| for (const stage of stages) { | ||
| for (const root of moduleRoots) { | ||
| const src = root ? path.join(stage.dir, root, 'build') : path.join(stage.dir, 'build'); | ||
| let isDir = false; | ||
| try { isDir = fs.statSync(src).isDirectory(); } catch {} | ||
| if (!isDir) continue; | ||
| const dest = root ? path.join(projectRoot, root, 'build') : path.join(projectRoot, 'build'); | ||
| fs.cpSync(src, dest, { recursive: true, force: true, dereference: false }); | ||
| copied++; | ||
| } | ||
| } | ||
| return copied; | ||
| } | ||
|
|
||
| function cleanupStage(dir) { | ||
| if (!dir) return; | ||
| try { fs.rmSync(dir, { recursive: true, force: true }); } catch {} | ||
| } | ||
|
|
||
| function cleanupStages(stages) { | ||
| if (!stages) return; | ||
| for (const s of stages) cleanupStage(s.dir); | ||
| } | ||
|
|
||
| // Remove a raw upload's temp dir (each upload lives in its own mkdtemp dir). | ||
| function cleanupUpload(uploadPath) { | ||
| if (!uploadPath) return; | ||
| try { fs.rmSync(path.dirname(uploadPath), { recursive: true, force: true }); } catch {} | ||
| } | ||
|
|
||
| module.exports = { | ||
| MAX_UPLOAD_BYTES, | ||
| MAX_UPLOAD_FILES, | ||
| sniffFormat, | ||
| extractArchive, | ||
| collectBuildMembers, | ||
| hashFile, | ||
| mergeStages, | ||
| overlayBuildDirs, | ||
| cleanupStage, | ||
| cleanupStages, | ||
| cleanupUpload, | ||
| }; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.