Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
bde1df2
Add comparison tool
falahat Jun 17, 2026
2d97385
feat(apphosting): implement N-Way Matrix comparison suite
falahat Jun 17, 2026
122775c
feat(apphosting): add static route discovery and comprehensive parity…
falahat Jun 17, 2026
5ab7a7f
feat(apphosting): harden compare tool, add routing delay and codebase…
falahat Jun 17, 2026
6222109
fix(apphosting): enable cross-testcase similarity calculations & grou…
falahat Jun 17, 2026
7c620ab
fix(apphosting): implement continuous HSL red-to-green gradient mappi…
falahat Jun 17, 2026
89851b5
fix(apphosting): harden typescript type safety across comparison suit…
falahat Jun 17, 2026
ace7c51
feat(apphosting): add dynamic heatmap filter controls for search, bui…
falahat Jun 17, 2026
4293338
feat(apphosting): implement fully dynamic, autocomplete-searchable me…
falahat Jun 17, 2026
1775dcc
docs(apphosting): remove obsolete next.js low similarity and express …
falahat Jun 17, 2026
4e3e8ff
Remediate pull request comments and secure vulnerabilities
falahat Jun 18, 2026
70c5568
Fix case-sensitive header comparisons to restore backward compatibili…
falahat Jun 18, 2026
7b1d8de
Improve readme
falahat Jun 18, 2026
ff1a0a7
feat(apphosting): improve dashboard layout, add github-style diff nav…
falahat Jun 22, 2026
48fd7c0
Implement lossless route latency recording and dynamic view-time head…
falahat Jun 22, 2026
e0db1bc
Add Response Time option to Matrix Score selector and implement globa…
falahat Jun 22, 2026
aeb817e
refactor(apphosting): decouple compare dashboard into static public a…
falahat Jun 22, 2026
11867a9
refactor(apphosting): migrate recordings cache to /tmp and decouple l…
falahat Jun 22, 2026
2df78d3
refactor(apphosting): redirect CLI output-dir to /tmp by default to p…
falahat Jun 22, 2026
abdae3e
style(apphosting): make heatmap grid cells adaptive and responsive on…
falahat Jun 22, 2026
90bb2a2
style(apphosting): enforce strict border-box square heatmap tiles
falahat Jun 22, 2026
16ccb7c
style(apphosting): implement fluid viewport-locked responsive square …
falahat Jun 22, 2026
b849637
ux(apphosting): consolidate matrix filters and collapsible ignored he…
falahat Jun 22, 2026
6b95796
fix(apphosting): resolve flexbox space collapsing in badge and force …
falahat Jun 22, 2026
84d0c68
feat(apphosting): decouple square cells into rigid inner divs to guar…
falahat Jun 23, 2026
36e4542
feat(apphosting): implement premium collapsible sidebar with localsto…
falahat Jun 23, 2026
5b490b7
feat(apphosting): implement dynamic relative coloring mode in heatmap…
falahat Jun 23, 2026
7370f1e
style(apphosting): remove Understanding Parity Metrics legend card to…
falahat Jun 23, 2026
568da80
feat(apphosting): replace generic Variant A/B placeholders with actua…
falahat Jun 23, 2026
e31a8a9
feat(apphosting): simplify test case names, tighten heatmap grid dens…
falahat Jun 23, 2026
16d082c
feat(apphosting): refine global matrix variant labels formatting to T…
falahat Jun 23, 2026
6b2035c
feat(apphosting): consolidate route comparison details into a unified…
falahat Jun 23, 2026
bdcd533
feat(apphosting): implement premium four-tab dashboard layout with si…
falahat Jun 23, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,6 @@ clean/
# Dart/Flutter
.dart_tool/
**/pubspec.lock

compare-report/
compare-cache/
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
"build:publish": "tsc --build tsconfig.publish.json && npm run copyfiles",
"build:watch": "npm run build && tsc --watch",
"clean": "node -e \"fs.rmSync('lib', { recursive: true, force: true }); fs.rmSync('dev', { recursive: true, force: true });\"",
"copyfiles": "node -e \"const fs = require('fs'); fs.copyFileSync('./src/dynamicImport.js', './lib/dynamicImport.js'); fs.cpSync('./src/firebase_studio', './lib/firebase_studio', {recursive: true, filter: (src) => fs.statSync(src).isDirectory() || src.endsWith('.md') || src.endsWith('.js')});\"",
"copyfiles": "node -e \"const fs = require('fs'); fs.copyFileSync('./src/dynamicImport.js', './lib/dynamicImport.js'); fs.cpSync('./src/firebase_studio', './lib/firebase_studio', {recursive: true, filter: (src) => fs.statSync(src).isDirectory() || src.endsWith('.md') || src.endsWith('.js')}); fs.cpSync('./src/apphosting/compare/public', './lib/apphosting/compare/public', {recursive: true});\"",
"format": "npm run format:ts && npm run format:other",
"format:other": "npm run lint:other -- --write",
"format:ts": "npm run lint:ts -- --fix --quiet",
Expand Down
136 changes: 136 additions & 0 deletions src/apphosting/compare/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
# App Hosting N-Way Matrix Comparison Tool

This is an experimental internal CLI tool built for the Firebase App Hosting team to dynamically verify the compatibility and performance of different infrastructure backends or application builds.

It takes an array of configurations (variants) and automatically deploys them, discovering their routes, and rendering an interactive $O(N^2)$ Pair-wise Cartesian Matrix heatmap dashboard.

## Capabilities

1. **Standard CLI Fidelity**: Deployments are executed by automatically constructing a temporary `firebase.json` and programmatically calling the `firebase deploy` CLI execution path. This guarantees that test deployments faithfully mirror the actual customer experience (including Secrets, AutoInit Env Vars, and custom headers).
2. **Local vs Remote Build Verification**: Can deploy locally built bundles (e.g. `localBuild: true`) side-by-side with remote Cloud Build source zips.
3. **Automated IAM & Secrets Management**: Intelligently creates a single mock secret in Secret Manager for each distinct codebase path, mapping the IAM `secretAccessor` roles simultaneously to all backends generated from that codebase.
4. **Dynamic Spidering**: Automatically crawls Next.js / Angular apps recursively starting from `/` to discover hidden dynamic routes, alongside statically parsing `.next/prerender-manifest.json`.
5. **Interactive Visualization Dashboard**: Hosts an interactive split-view dashboard showcasing global parity heatmaps, dynamic filtering, code diffs, and exact header mismatches.

## Usage

1. Create a `matrix-test.json` file to define your test cases:

```json
[
{
"name": "Node Matrix Test",
"variants": [
{
"id": "Local-Node24",
"path": "../next-sample-1",
"localBuild": true,
"runtime": "nodejs24"
},
{
"id": "Source-Node24",
"path": "../next-sample-1",
"localBuild": false,
"runtime": "nodejs24"
}
]
}
]
```

2. Run the deployment and recording phase:

```bash
FIREBASE_CLI_EXPERIMENTS=apphosting firebase apphosting:compare-suite --project <your-project> --suite-config src/apphosting/compare/matrices/matrix-test.json --record-only
```

3. Spin up the visual dashboard to inspect the matrices and diffs:

```bash
FIREBASE_CLI_EXPERIMENTS=apphosting firebase apphosting:compare-suite --serve --port 3000
```


## System Architecture & Subsystem Delineation

The N-Way Matrix Comparison Tool is divided into two distinct subsystems to separate the CLI recording/deployment engine from the visual comparison dashboard (the Diff Viewer).


```mermaid
graph TD
%% Define styles
classDef diffViewer fill:#1e293b,stroke:#3b82f6,stroke-width:2px,color:#f8fafc;
classDef engine fill:#0f172a,stroke:#334155,stroke-width:1px,color:#94a3b8;
classDef ui fill:#1e1b4b,stroke:#818cf8,stroke-width:2px,color:#f8fafc;
classDef cache fill:#022c22,stroke:#10b981,stroke-width:2px,color:#f8fafc;

subgraph DiffViewerGroup["Diff Viewer Web Application (Visual Dashboard)"]
UI["User Interface (Web Dashboard UI)<br/>index.html / index.css / index.js"]:::ui
Server["Diff Viewer API Server<br/>server.ts"]:::diffViewer
end

subgraph EngineGroup["Deployment & Recording Engine (CLI Runner)"]
Cache["Cache Manager (JSON)<br/>cache.ts"]:::cache
Comp["Comparison Engine<br/>compare.ts"]:::engine
Crawler["Crawler & Route Discovery<br/>crawler.ts / discover.ts"]:::engine
Orch["Orchestration Engine<br/>suite.ts / lifecycle.ts"]:::engine
end

%% Relationships
UI -- "HTTP / API Requests" --> Server
Server -- "Read JSON Cache" --> Cache
Cache -- "Write Telemetry" --> Comp
Comp -- "Run Parity Analysis" --> Crawler
Crawler -- "Spider Dynamic Paths" --> Orch

class UI,Server diffViewer;
class Cache cache;
class Comp,Crawler,Orch engine;
```


### 1. The Diff Viewer Web Application (Visual Dashboard)
This subsystem is responsible for hosting the interactive web interface, serving visualization assets, and exposing the matrix-calculation APIs.
* **`src/apphosting/compare/public/`**: Contains the frontend single-page application:
* `index.html`: Layout structure, split panes, controls bar, and dialog anchors.
* `index.css`: Stylesheet, custom dark-mode slate theme, responsive grid cards, and transition overlays.
* `index.js`: Client-side logic, real-time matrix rendering using HSL interpolation, localStorage configuration persistence, inline table ignores, and dynamic filters.
* **`src/apphosting/compare/server.ts`**: The lightweight Express API backend. It reads recorded JSON files from the cache directory, serves the static assets from the `public/` directory, and provides endpoints:
* `/api/recordings`: Lists all available cached test suite recordings.
* `/api/matrix`: Recalculates pairwise parity similarity scores dynamically.
* `/api/compare`: Performs exact body and header diffs between two selected variant recordings.
* `/api/render`: Proxies the recorded response bodies as live visual rendering previews.

### 2. The Deployment & Recording Engine (CLI Runner)
This subsystem orchestrates the lifecycle of deploying temporary slots, discovering routes, spidering endpoints, and persisting telemetry.
* **`src/apphosting/compare/suite.ts`**: The main execution orchestrator for the CLI command. It processes test matrices, locks concurrency slots, and schedules deployment loops.
* **`src/apphosting/compare/lifecycle.ts`**: Handles programmatic interactions with the Firebase CLI (`firebase deploy`) and cleanups.
* **`src/apphosting/compare/discover.ts`**: Parses local build metadata (such as the Next.js pre-render manifest) to compile an initial target route list.
* **`src/apphosting/compare/crawler.ts`**: A recursive spider crawler that walks paths on live deployments to auto-discover dynamic routes.
* **`src/apphosting/compare/cache.ts`**: Manages reading and writing serialization records under the `.compare-cache/` workspace directory.
* **`src/apphosting/compare/compare.ts`**: Core parity engine containing the text similarity scoring (Levenshtein/Jaccard), status check rules, and header variation classifications.
* **`src/apphosting/compare/distance.ts`**: Algorithmic library for calculating string similarity distances.

---


## Instructions for AI Agents

If you are an AI Agent tasked with running differential tests or adding new variants to a matrix, follow these strict guidelines:

### 1. Matrix Configuration
* Matrices must be an array of test cases. Each test case MUST have at least 2 variants.
* Always point `path` to the correct target framework codebase directory relative to your working directory.
* Valid configuration flags inside a variant: `id` (string), `path` (string), `localBuild` (boolean), `runtime` (string).

### 2. Execution Phases
The tool separates deploying/crawling from visual analysis.
1. **Record Phase**: Run the suite using `--record-only` to deploy to App Hosting and generate the cache. This takes several minutes per variant. Wait for the command to finish.
* *Note: The tool globally locks GCP slots (e.g. `compare-slot-1-0`). Do NOT run multiple record suites concurrently.*
2. **Analyze Phase**: Once recording is complete, start the dashboard using `--serve --port 8000` in the background (using `WaitMsBeforeAsync: 3000`).

### 3. Visual Verification
* You CANNOT read the raw test outputs or diff matrices from the terminal. All comparison analysis must be done visually.
* After starting the `--serve` server, use your `browser_subagent` to open `http://localhost:8000`.
* Instruct the browser subagent to click the heatmap cells, toggle the "Visual Split-View" tabs, apply "Runtime" or "Build Origin" dynamic dropdown filters, and capture screenshots of the diffs.
* Read the screenshots returned by the subagent to interpret the parity results or detect UI rendering regressions.
193 changes: 193 additions & 0 deletions src/apphosting/compare/cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
import * as fs from "fs-extra";
import * as path from "path";
import * as crypto from "crypto";
import * as os from "os";
import { logger } from "./logger";

export interface RouteResponse {
status: number;
headers: Record<string, string>;
body: string;
isBinary: boolean;
latencyMs?: number;
}

export interface VariantRecording {
id: string;
testCaseName: string;
timestamp: string;
url: string;
routes: Record<string, RouteResponse>;
localBuild?: boolean;
runtime?: string;
deployTimeMs?: number;
path?: string;
}

export function isRouteResponse(obj: unknown): obj is RouteResponse {

Check warning on line 27 in src/apphosting/compare/cache.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Missing JSDoc comment
if (typeof obj !== "object" || obj === null) return false;
const o = obj as Record<string, unknown>;

Check failure on line 30 in src/apphosting/compare/cache.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Delete `··`

Check failure on line 30 in src/apphosting/compare/cache.ts

View workflow job for this annotation

GitHub Actions / unit (24)

Delete `··`

Check failure on line 30 in src/apphosting/compare/cache.ts

View workflow job for this annotation

GitHub Actions / unit (24)

Delete `··`
if (!(

Check failure on line 31 in src/apphosting/compare/cache.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Insert `⏎····`

Check failure on line 31 in src/apphosting/compare/cache.ts

View workflow job for this annotation

GitHub Actions / unit (24)

Insert `⏎····`

Check failure on line 31 in src/apphosting/compare/cache.ts

View workflow job for this annotation

GitHub Actions / unit (24)

Insert `⏎····`
typeof o.status === "number" &&

Check failure on line 32 in src/apphosting/compare/cache.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Insert `··`

Check failure on line 32 in src/apphosting/compare/cache.ts

View workflow job for this annotation

GitHub Actions / unit (24)

Insert `··`

Check failure on line 32 in src/apphosting/compare/cache.ts

View workflow job for this annotation

GitHub Actions / unit (24)

Insert `··`
typeof o.headers === "object" && o.headers !== null &&

Check failure on line 33 in src/apphosting/compare/cache.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Replace `····typeof·o.headers·===·"object"·&&` with `······typeof·o.headers·===·"object"·&&⏎·····`

Check failure on line 33 in src/apphosting/compare/cache.ts

View workflow job for this annotation

GitHub Actions / unit (24)

Replace `····typeof·o.headers·===·"object"·&&` with `······typeof·o.headers·===·"object"·&&⏎·····`

Check failure on line 33 in src/apphosting/compare/cache.ts

View workflow job for this annotation

GitHub Actions / unit (24)

Replace `····typeof·o.headers·===·"object"·&&` with `······typeof·o.headers·===·"object"·&&⏎·····`
typeof o.body === "string" &&

Check failure on line 34 in src/apphosting/compare/cache.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Insert `··`

Check failure on line 34 in src/apphosting/compare/cache.ts

View workflow job for this annotation

GitHub Actions / unit (24)

Insert `··`

Check failure on line 34 in src/apphosting/compare/cache.ts

View workflow job for this annotation

GitHub Actions / unit (24)

Insert `··`
typeof o.isBinary === "boolean"

Check failure on line 35 in src/apphosting/compare/cache.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Replace `····` with `······`

Check failure on line 35 in src/apphosting/compare/cache.ts

View workflow job for this annotation

GitHub Actions / unit (24)

Replace `····` with `······`

Check failure on line 35 in src/apphosting/compare/cache.ts

View workflow job for this annotation

GitHub Actions / unit (24)

Replace `····` with `······`
)) {

Check failure on line 36 in src/apphosting/compare/cache.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Replace `)` with `··)⏎··`

Check failure on line 36 in src/apphosting/compare/cache.ts

View workflow job for this annotation

GitHub Actions / unit (24)

Replace `)` with `··)⏎··`

Check failure on line 36 in src/apphosting/compare/cache.ts

View workflow job for this annotation

GitHub Actions / unit (24)

Replace `)` with `··)⏎··`
return false;
}

if (o.latencyMs !== undefined && typeof o.latencyMs !== "number") {
return false;
}

return true;
}

export function isVariantRecording(obj: unknown): obj is VariantRecording {

Check warning on line 47 in src/apphosting/compare/cache.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Missing JSDoc comment
if (typeof obj !== "object" || obj === null) return false;
const o = obj as Record<string, unknown>;

Check failure on line 50 in src/apphosting/compare/cache.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Delete `··`

Check failure on line 50 in src/apphosting/compare/cache.ts

View workflow job for this annotation

GitHub Actions / unit (24)

Delete `··`

Check failure on line 50 in src/apphosting/compare/cache.ts

View workflow job for this annotation

GitHub Actions / unit (24)

Delete `··`
if (!(

Check failure on line 51 in src/apphosting/compare/cache.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Insert `⏎····`

Check failure on line 51 in src/apphosting/compare/cache.ts

View workflow job for this annotation

GitHub Actions / unit (24)

Insert `⏎····`

Check failure on line 51 in src/apphosting/compare/cache.ts

View workflow job for this annotation

GitHub Actions / unit (24)

Insert `⏎····`
typeof o.id === "string" &&

Check failure on line 52 in src/apphosting/compare/cache.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Insert `··`

Check failure on line 52 in src/apphosting/compare/cache.ts

View workflow job for this annotation

GitHub Actions / unit (24)

Insert `··`

Check failure on line 52 in src/apphosting/compare/cache.ts

View workflow job for this annotation

GitHub Actions / unit (24)

Insert `··`
typeof o.testCaseName === "string" &&
typeof o.timestamp === "string" &&
typeof o.url === "string" &&
typeof o.routes === "object" && o.routes !== null
)) {
return false;
}

if (o.localBuild !== undefined && typeof o.localBuild !== "boolean") {
return false;
}

if (o.runtime !== undefined && typeof o.runtime !== "string") {
return false;
}

if (o.deployTimeMs !== undefined && typeof o.deployTimeMs !== "number") {
return false;
}

const routes = o.routes as Record<string, unknown>;
for (const key of Object.keys(routes)) {
if (!isRouteResponse(routes[key])) {
return false;
}
}

return true;
}

function isErrnoException(error: unknown): error is NodeJS.ErrnoException {
return error instanceof Error && "code" in error;
}

const CACHE_DIR = process.platform === "win32"
? path.join(os.tmpdir(), "firebase-apphosting-compare-cache")
: "/tmp/firebase-apphosting-compare-cache";

function getRecordingPath(testCaseName: string, variantId: string): string {
const tcHash = crypto.createHash("sha256").update(testCaseName).digest("hex").slice(0, 8);
const vHash = crypto.createHash("sha256").update(variantId).digest("hex").slice(0, 8);
const safeTestCase = `${testCaseName.replace(/[^a-zA-Z0-9_-]/g, "_")}_${tcHash}`;
const safeVariant = `${variantId.replace(/[^a-zA-Z0-9_-]/g, "_")}_${vHash}`;
const resolvedPath = path.resolve(path.join(CACHE_DIR, "recordings", safeTestCase, `${safeVariant}.json`));

if (!resolvedPath.startsWith(path.resolve(CACHE_DIR))) {
throw new Error("Path traversal restriction violation");
}
return resolvedPath;
}

/**
* Saves a variant recording to the cache atomically.
*/
export async function saveRecording(recording: VariantRecording): Promise<void> {
const filePath = getRecordingPath(recording.testCaseName, recording.id);
const tempPath = filePath + ".tmp";

await fs.ensureDir(path.dirname(tempPath));
await fs.writeJson(tempPath, recording, { spaces: 2 });
await fs.rename(tempPath, filePath);

logger.info(`Saved recording to cache: ${filePath}`);
}

/**
* Loads a variant recording from the cache.
*/
export async function loadRecording(testCaseName: string, variantId: string): Promise<VariantRecording> {
const filePath = getRecordingPath(testCaseName, variantId);
if (!(await fs.pathExists(filePath))) {
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
throw new Error(`No recording found in cache for variant "${variantId}" under test case "${testCaseName}"`);
}
const data = await fs.readJson(filePath);

Check warning on line 126 in src/apphosting/compare/cache.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unsafe assignment of an `any` value
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
if (!isVariantRecording(data)) {
throw new Error(`Invalid recording format in cache for variant "${variantId}" under test case "${testCaseName}"`);
}
return data;
}

/**
* Lists all recorded test cases and their variants.
*/
export async function listRecordings(): Promise<Record<string, string[]>> {
const recordingsDir = path.join(CACHE_DIR, "recordings");
if (!(await fs.pathExists(recordingsDir))) {
return {};
}

const result: Record<string, string[]> = {};
try {
const testCases = await fs.readdir(recordingsDir);
for (const tc of testCases) {
const tcDir = path.join(recordingsDir, tc);
try {
const stat = await fs.stat(tcDir);
if (!stat.isDirectory()) {
continue;
}
const files = await fs.readdir(tcDir);
const jsonFiles = files.filter((file) => file.endsWith(".json"));
if (jsonFiles.length === 0) {
continue;
}
const variantIds: string[] = [];
let originalTestCaseName = "";
for (const file of jsonFiles) {
try {
const data = await fs.readJson(path.join(tcDir, file));

Check warning on line 161 in src/apphosting/compare/cache.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unsafe assignment of an `any` value
if (isVariantRecording(data)) {
originalTestCaseName = data.testCaseName;
variantIds.push(data.id);
} else {
logger.debug(`Cache file ${file} does not match VariantRecording schema`);
}
} catch (readErr) {
// If a file is partially written or corrupted, skip it
logger.debug(`Failed to read metadata for cache file: ${file}`, readErr);
}
}
if (originalTestCaseName && variantIds.length > 0) {
result[originalTestCaseName] = variantIds;
}
} catch (err: unknown) {
if (isErrnoException(err) && err.code === "ENOENT") {
continue; // Directory was concurrently deleted/moved
}
throw err;
}
}
} catch (err: unknown) {
if (isErrnoException(err) && err.code === "ENOENT") {
return {};
}
throw err;
}

return result;
}


Loading
Loading