diff --git a/demo-ecommerce/abtests.config.ts b/demo-ecommerce/abtests.config.ts
index 36241934..df938442 100644
--- a/demo-ecommerce/abtests.config.ts
+++ b/demo-ecommerce/abtests.config.ts
@@ -63,6 +63,7 @@ export default defineConfig({
browser: 'chromium',
args: ['--no-sandbox'],
},
+ viewports: ['phone'],
maxNumDiffPixels: 50,
defaultMisMatchThreshold: 0.1,
},
@@ -76,8 +77,9 @@ export default defineConfig({
// `screenEmulation` are NOT set here — the viewport referenced from
// shared.viewports owns them; the runner lowers them via
// lhConfigForViewport.
+ viewports: ['phone'],
lighthouseConfig: LIGHTHOUSE_CONFIG,
- numberOfMeasurements: 10,
+ numberOfMeasurements: 8,
pValueThreshold: 0.01,
},
@@ -85,6 +87,19 @@ export default defineConfig({
lighthouseConfig: LIGHTHOUSE_CONFIG,
},
+ bisect: {
+ rebuildCommands: [
+ {
+ description: 'Install JavaScript dependencies',
+ command: 'yarn install --immutable',
+ },
+ {
+ description: 'Precompile application assets',
+ command: 'rm -rf public/packs tmp/cache && SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompile',
+ },
+ ],
+ },
+
twinServers: {
experimentDir: process.cwd(),
controlDir: process.env.CONTROL_REPO_DIR || '../../shaka-perf-control/demo-ecommerce',
diff --git a/demo-ecommerce/app/javascript/components/pages/HomePage.tsx b/demo-ecommerce/app/javascript/components/pages/HomePage.tsx
index ed1e5079..fbd28b18 100644
--- a/demo-ecommerce/app/javascript/components/pages/HomePage.tsx
+++ b/demo-ecommerce/app/javascript/components/pages/HomePage.tsx
@@ -17,17 +17,42 @@ import LoadingSpinner from '../shared/LoadingSpinner';
import LazySection from '../shared/LazySection';
import ExperimentA11yRegressions from '../shared/ExperimentA11yRegressions';
+const BisectAccessibilityProbe: React.FC = () => (
+
+);
+
+const runMerchandisingWarmup = () => {
+ const deadline = performance.now() + 450;
+ let checksum = 0;
+
+ while (performance.now() < deadline) {
+ checksum += Math.sqrt(checksum + 1);
+ }
+
+ if (checksum === Number.POSITIVE_INFINITY) {
+ console.info(checksum);
+ }
+};
+
const HomePage: React.FC = () => {
const { products, loading, error } = useProducts();
const featuredProducts = products.filter((p) => p.featured).slice(0, 4);
+ React.useEffect(() => {
+ runMerchandisingWarmup();
+ }, []);
+
return (
{/* Hero Section */}
{
+
{/* Features Section */}
diff --git a/demo-ecommerce/app/javascript/components/pages/ProductDetailPage.tsx b/demo-ecommerce/app/javascript/components/pages/ProductDetailPage.tsx
index 62f98120..ae1a690a 100644
--- a/demo-ecommerce/app/javascript/components/pages/ProductDetailPage.tsx
+++ b/demo-ecommerce/app/javascript/components/pages/ProductDetailPage.tsx
@@ -24,11 +24,28 @@ import { useCart } from '../../hooks/useCart';
import LoadingSpinner from '../shared/LoadingSpinner';
import ProductFeatures from '../shared/ProductFeatures';
+const runRecommendationScoring = () => {
+ const deadline = performance.now() + 350;
+ let checksum = 0;
+
+ while (performance.now() < deadline) {
+ checksum += Math.sqrt(checksum + 1);
+ }
+
+ if (checksum === Number.POSITIVE_INFINITY) {
+ console.info(checksum);
+ }
+};
+
const ProductDetailPage: React.FC = () => {
const { id } = useParams<{ id: string }>();
const { product, loading, error } = useProduct(Number(id));
const { addToCart } = useCart();
+ React.useEffect(() => {
+ runRecommendationScoring();
+ }, []);
+
if (loading) {
return ;
}
@@ -78,6 +95,7 @@ const ProductDetailPage: React.FC = () => {
maxHeight: 400,
objectFit: 'cover',
borderRadius: 2,
+ boxShadow: '0 0 0 8px rgba(180, 83, 9, 0.35)',
}}
/>
diff --git a/demo-ecommerce/docs/git-bisect-seed-history.md b/demo-ecommerce/docs/git-bisect-seed-history.md
new file mode 100644
index 00000000..0d4a422e
--- /dev/null
+++ b/demo-ecommerce/docs/git-bisect-seed-history.md
@@ -0,0 +1,90 @@
+# Git Bisect Seed History
+
+This branch is intentionally shaped for future shaka-perf git bisect testing.
+Commits alternate between harmless demo maintenance and deterministic regression
+fixtures so category-specific bisect flows have clear first-bad targets.
+
+The regression fixtures are artificial by design, but each one is scoped to the
+demo app and kept easy to inspect.
+
+Clean commits in this branch should stay runtime-neutral. They can update this
+note or other demo-only documentation without changing measured pages.
+
+Runtime fixtures should prefer existing measured routes so future bisect tests
+do not need a special-case demo configuration.
+
+The homepage fixtures are intended for the existing homepage and shop-now
+coverage. The product detail fixture is intended for the existing product detail
+coverage.
+
+Performance fixtures should use deterministic client-side work instead of
+network delays so results remain easy to reproduce in local containers.
+
+Accessibility fixtures should avoid layout-visible changes when they are meant
+to be accessibility-only.
+
+Clean commits should be safe to classify as good for every category that has
+not yet received its first dedicated regression fixture.
+
+Combined regression commits are useful for checking that category-specific
+bisect still reports the earliest bad commit for each individual category.
+
+Product detail fixtures should avoid changing product data or cart behavior, so
+they remain independent of Rails seeds and API responses.
+
+Fixture commits should stay small enough that `git show` explains the category
+signal without requiring a full app run.
+
+Seed base: `38dae68` (`main` when this branch was created).
+
+Category map:
+
+- `623a1ae` - no regression, seed docs only.
+- `fe8900e` - no regression, docs only.
+- `58cc828` - first `visreg` bad commit, homepage hero color change.
+- `a55e7f4` - no regression, docs only.
+- `754fcd9` - no regression, docs only.
+- `9c7cfff` - first `perf` bad commit, homepage CPU warmup.
+- `744fe90` - no regression, docs only.
+- `ac38e53` - no regression, docs only.
+- `463c429` - no regression, docs only.
+- `fcb0e2b` - first `accessibility` bad commit, offscreen unnamed button.
+- `c1e2a62` - no regression, docs only.
+- `ce1f601` - no regression, docs only.
+- `3846371` - no regression, docs only.
+- `5345dff` - visual plus performance regression on product detail.
+- `088afb9` - no regression, docs only.
+- `4406a78` - no regression, fixture-impact docs only.
+
+Expected affected AB tests and metrics:
+
+- `Homepage` / `visreg` - screenshot mismatch and diff pixels for
+ `[data-cy="hero-section"]` and `document` after the hero gradient changes.
+- `Homepage` / `perf` - primarily worse `TBT` and lower `LH Score` from the
+ 450ms homepage CPU warmup. `speed-index`, `FCP`, and `LCP` can also move
+ depending on timing.
+- `Homepage` / `accessibility` - a new axe finding, expected to be
+ `button-name`, from `button[data-cy="bisect-a11y-probe"]`.
+- `Click Shop Now on the homepage` / `perf` - primarily worse `TBT` and lower
+ `LH Score` in the phone viewport because the test starts on `/` and runs the
+ homepage CPU warmup.
+- `Product Detail` / `visreg` - screenshot mismatch and diff pixels from the
+ product image `boxShadow`.
+- `Product Detail` / `perf` - primarily worse `TBT` and lower `LH Score` from
+ the 350ms product-detail CPU warmup.
+- `Product Detail - Show Product Journey Toggle` / `visreg` - likely full-page
+ screenshot mismatch from the product image `boxShadow` in the phone viewport.
+
+Expected unaffected AB tests:
+
+- `Product Detail - Desktop Actions` captures only
+ `[data-cy="product-actions-desktop"]`, so the product image `boxShadow` should
+ not appear in its screenshot.
+- `Click Reviews on Product Detail` and
+ `Product Details => Click on Reviews => Click on Deals` end on reviews or
+ deals pages and run as visual-only tests, so the product-detail image change
+ should not be captured.
+- `Products List`, `Cart`, `Carousel`, and `Admin` do not visit touched routes
+ or changed fixtures.
+- Product-detail accessibility should stay unchanged because no semantic
+ accessibility regression was added there.
diff --git a/packages/shaka-perf/src/compare/bisect/__tests__/seed-history.test.ts b/packages/shaka-perf/src/compare/bisect/__tests__/seed-history.test.ts
new file mode 100644
index 00000000..1b845cf8
--- /dev/null
+++ b/packages/shaka-perf/src/compare/bisect/__tests__/seed-history.test.ts
@@ -0,0 +1,189 @@
+/*
+ * Copyright (c) 2026 ShakaCode LLC.
+ *
+ * SPDX-License-Identifier: LicenseRef-ShakaPerf-1.0
+ *
+ * This file is part of ShakaPerf. Use is governed by The ShakaPerf
+ * License in LICENSE.md.
+ */
+
+import * as fs from 'node:fs';
+import * as path from 'node:path';
+import { applyCachedObservations, applyObservations, nextCandidate } from '../search';
+import type {
+ BisectCategory,
+ BisectSession,
+ BisectTarget,
+ TargetObservation,
+} from '../types';
+
+const seedCommits = [
+ '38dae6871b8b443dd1880269dacde951700e77cc',
+ '623a1ae6f39cb6cbddd550ecd33d83c670877d14',
+ 'fe8900e2568f11d05e41d43ad62e0aa863017264',
+ '58cc828b7272cd69408fa4dc5cd36206dcd8846a',
+ 'a55e7f44dea86afa94775cf448dc3e696c71ed33',
+ '754fcd9b72b5623118a9bc1fb84b87ec98e002e1',
+ '9c7cfff6c0ca9bd561f5bb9905a1b09ee3132d1e',
+ '744fe902ab6b087f761f2df7e1f52bcc2e88d1c1',
+ 'ac38e5320e6c33b67474f445c15e3012e22fb491',
+ '463c4296e003fafe5dc4e23f5f43e805e555938d',
+ 'fcb0e2b107a99c6e4edab01da114d4d83b3d7a94',
+ 'c1e2a62486870b02354c0c5b8727f8944e4913a3',
+ 'ce1f6015bfd01e05228d94affb788fe5f0d896a0',
+ '3846371172486d851b836883c54520cc1b844199',
+ '5345dffb62b761b9cb0e1516a6bbd4389a6cf642',
+ '088afb9342d8c4337361df177e3731550b096fc9',
+ '4406a7800cfec9af52e9f7e731a1ad59915ac227',
+ 'f7b872f2a6d5817be15261b4d9f21a4f6814126f',
+];
+
+const seedRegressionsByCommit = {
+ '58cc828b7272cd69408fa4dc5cd36206dcd8846a': ['homepage-hero-visual'],
+ '9c7cfff6c0ca9bd561f5bb9905a1b09ee3132d1e': ['homepage-tbt'],
+ 'fcb0e2b107a99c6e4edab01da114d4d83b3d7a94': ['homepage-button-name'],
+ '5345dffb62b761b9cb0e1516a6bbd4389a6cf642': [
+ 'product-detail-visual',
+ 'product-detail-tbt',
+ ],
+} as const;
+
+type SeedTargetId =
+ (typeof seedRegressionsByCommit)[keyof typeof seedRegressionsByCommit][number];
+
+const expectedFirstBad = {
+ 'homepage-hero-visual': '58cc828b7272cd69408fa4dc5cd36206dcd8846a',
+ 'homepage-tbt': '9c7cfff6c0ca9bd561f5bb9905a1b09ee3132d1e',
+ 'homepage-button-name': 'fcb0e2b107a99c6e4edab01da114d4d83b3d7a94',
+ 'product-detail-visual': '5345dffb62b761b9cb0e1516a6bbd4389a6cf642',
+ 'product-detail-tbt': '5345dffb62b761b9cb0e1516a6bbd4389a6cf642',
+} as const;
+
+function target(
+ id: keyof typeof expectedFirstBad,
+ category: BisectCategory,
+ testFile: string,
+ testName: string,
+ subject: string,
+): BisectTarget {
+ return {
+ id,
+ category,
+ testFile,
+ testName,
+ viewport: 'desktop',
+ subject,
+ status: 'active',
+ goodIndex: 0,
+ badIndex: seedCommits.length - 1,
+ observations: {},
+ };
+}
+
+function observation(
+ targetId: string,
+ commitSha: string,
+ present: boolean,
+): TargetObservation {
+ return {
+ targetId,
+ commitSha,
+ present,
+ values: { fixture: true },
+ artifacts: [],
+ };
+}
+
+function seedSession(): BisectSession {
+ return {
+ version: 1,
+ status: 'running',
+ goodSha: seedCommits[0],
+ badSha: seedCommits.at(-1)!,
+ originalExperiment: { sha: seedCommits.at(-1)!, branch: 'codex/git-bisect-demo-history' },
+ selectedCategories: ['visreg', 'perf', 'accessibility'],
+ orderedCommits: seedCommits,
+ targets: [
+ target(
+ 'homepage-hero-visual',
+ 'visreg',
+ 'demo-ecommerce/ab-tests/homepage.abtest.ts',
+ 'Homepage',
+ '[data-cy="hero-section"]',
+ ),
+ target('homepage-tbt', 'perf', 'demo-ecommerce/ab-tests/homepage.abtest.ts', 'Homepage', 'TBT'),
+ target(
+ 'homepage-button-name',
+ 'accessibility',
+ 'demo-ecommerce/ab-tests/homepage.abtest.ts',
+ 'Homepage',
+ 'button-name',
+ ),
+ target(
+ 'product-detail-visual',
+ 'visreg',
+ 'demo-ecommerce/ab-tests/product-detail.abtest.ts',
+ 'Product Detail',
+ 'document',
+ ),
+ target(
+ 'product-detail-tbt',
+ 'perf',
+ 'demo-ecommerce/ab-tests/product-detail.abtest.ts',
+ 'Product Detail',
+ 'TBT',
+ ),
+ ],
+ commitRuns: {},
+ startedAt: '2026-07-12T00:00:00.000Z',
+ };
+}
+
+describe('demo ecommerce bisect seed history fixture', () => {
+ it('clears persistent build output before precompiling each candidate', () => {
+ const configPath = path.resolve(
+ __dirname,
+ '../../../../../../demo-ecommerce/abtests.config.ts',
+ );
+
+ expect(fs.readFileSync(configPath, 'utf8')).toContain(
+ "command: 'rm -rf public/packs tmp/cache && SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompile'",
+ );
+ });
+
+ it('finds the documented first bad commit for every seeded regression target', () => {
+ let session = seedSession();
+
+ while (true) {
+ const normalized = applyCachedObservations(session);
+ const work = nextCandidate(normalized);
+ if (!work) {
+ session = normalized;
+ break;
+ }
+ const candidateIndex = seedCommits.indexOf(work.sha);
+ const regressionsPresent = new Set(
+ seedCommits
+ .slice(0, candidateIndex + 1)
+ .flatMap(
+ (sha) => seedRegressionsByCommit[sha as keyof typeof seedRegressionsByCommit] ?? [],
+ ),
+ );
+ session = applyObservations(normalized, work.sha, new Map(
+ work.targetIds.map((targetId) => [
+ targetId,
+ observation(
+ targetId,
+ work.sha,
+ regressionsPresent.has(targetId as SeedTargetId),
+ ),
+ ]),
+ ));
+ }
+
+ expect(Object.fromEntries(
+ session.targets.map((item) => [item.id, item.firstBadSha]),
+ )).toEqual(expectedFirstBad);
+ expect(session.targets.every((item) => item.status === 'found')).toBe(true);
+ });
+});