Skip to content

Add CI performance budgets and observability checks#99

Merged
shayancoin merged 3 commits intomainfrom
codex/extend-performance-budget-configuration
Oct 17, 2025
Merged

Add CI performance budgets and observability checks#99
shayancoin merged 3 commits intomainfrom
codex/extend-performance-budget-configuration

Conversation

@shayancoin
Copy link
Owner

@shayancoin shayancoin commented Oct 16, 2025

Summary

  • add a Playwright-driven performance budget test for the homepage to configurator journey and write structured metrics
  • integrate a new CI perf-budget job that boots the stack, runs the Playwright check, and publishes metrics via Pushgateway or JUnit
  • add a Prometheus/Tempo regression gate script plus workflow job and document the release checklist connecting Grafana alerts to on-call routing

Testing

  • npm --prefix frontend run lint

https://chatgpt.com/codex/tasks/task_e_68f12ebe0cec8330a2ee49fc1503621d

Summary by CodeRabbit

  • New Features

    • Added performance budget testing with journey-based metrics collection and threshold validation.
    • Added performance metrics publishing to Prometheus Pushgateway and JUnit reporting.
  • Documentation

    • Added Release Checklist section.
  • Chores

    • Added TypeScript type definitions for YAML support.
    • Added canary budget validation script with Prometheus and Tempo integration.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Oct 16, 2025

Caution

Review failed

The pull request is closed.

Note

Other AI code review bot(s) detected

CodeRabbit has detected other AI code review bot(s) in this pull request and will avoid duplicating their findings in the review comments. This may lead to a less comprehensive review.

Walkthrough

This PR introduces end-to-end performance budget testing and CI observability infrastructure, including a Playwright test suite to measure page navigation and LCP metrics, scripts to validate canary budgets via Prometheus and Tempo, a metrics publishing pipeline for Prometheus Pushgateway or JUnit reports, TypeScript type definitions for YAML parsing, and updated documentation.

Changes

Cohort / File(s) Summary
Frontend performance test suite
frontend/package.json, frontend/tests/e2e/perf/perf-budget.spec.ts
Added @types/js-yaml for TypeScript type support. New Playwright test suite to load perf-budget.yml config, run performance journeys with multiple iterations, measure navigation duration and LCP metrics, compute percentiles, and assert results against configured thresholds.
CI canary observability
scripts/ci/__init__.py, scripts/ci/check_canary_budgets.py
Added module docstring to CI helpers package. New script that queries Prometheus and Tempo to validate latency and error budgets, computes regressions, evaluates pass/fail status, and reports results with structured output.
Performance metrics publishing
scripts/publish-perf-metrics.mjs
New Node.js script to collect performance results from JSON files, format metrics in Prometheus text format or JUnit XML, and publish to Prometheus Pushgateway or write to disk.
Documentation
mkdocs.yml
Added Release Checklist navigation entry after Getting Started section.

Sequence Diagram(s)

sequenceDiagram
    participant PW as Playwright Test
    participant Config as perf-budget.yml
    participant Browser as Browser
    participant Results as Results JSON
    participant Metrics as Metrics Publisher
    participant PG as Prometheus/JUnit

    PW->>Config: Load YAML config
    Note over PW: Parse journeys & metrics
    loop For each journey iteration
        PW->>Browser: Navigate from→to
        PW->>Browser: Collect LCP & nav metrics
        PW->>Results: Store run metrics
    end
    PW->>Results: Aggregate percentiles (p90/p95)
    PW->>PW: Assert vs thresholds
    
    Metrics->>Results: Read JSON files
    alt PUSHGATEWAY_URL set
        Metrics->>PG: POST Prometheus metrics
    else
        Metrics->>PG: Write JUnit report
    end
Loading
sequenceDiagram
    participant CI as CI Pipeline
    participant Prom as Prometheus
    participant Tempo as Tempo
    participant Check as check_canary_budgets.py

    CI->>Check: Invoke with config
    Check->>Prom: Query current metrics
    Check->>Prom: Query baseline metrics
    Check->>Check: Compute regression & evaluate
    alt Budget threshold exceeded
        Check->>Check: Mark failed
    end
    opt Tempo enabled
        Check->>Tempo: Query trace durations
        Check->>Check: Check regression vs tolerance
    end
    Check->>CI: Report results & exit status
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~65 minutes

The diff introduces substantial new functionality across multiple domains: a feature-rich Playwright test suite with custom metrics collection and configuration parsing; a CI observability script with Prometheus/Tempo integration and regression analysis; a metrics publishing pipeline; and TypeScript dependencies. While individual pieces follow clear patterns, the heterogeneous nature of changes (test infrastructure, CI scripts, publishing, config), multiple files with distinct logic, and new type definitions and public functions require careful cross-functional review.

Possibly related PRs

Poem

🐰 Hop, collect, and measure true,
Percentiles in p90 and p95 brew!
Prometheus queries, Tempo's trace divine,
Performance budgets now align,
Metrics published, thresholds in sight—
Our observability shines bright!

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Description Check ⚠️ Warning The description does not adhere to the repository’s template because it omits the required PR Type, Short Description, and Tests Added sections and uses non-standard headings. It currently uses '## Summary' and '## Testing' instead of the specified headings. This prevents reviewers from quickly locating the mandated information. Please update the PR description to use the repository’s template headings, including a PR Type field, a concise Short Description, and a Tests Added section detailing new tests, and move or reformat the summary and testing details accordingly.
✅ Passed checks (2 passed)
Check name Status Explanation
Title Check ✅ Passed The title succinctly captures the key additions of CI performance budgets and observability checks, which correspond to the new workflow jobs and scripts introduced. It clearly reflects the main change without extraneous details. This makes the purpose of the pull request immediately clear to reviewers.
Docstring Coverage ✅ Passed No functions found in the changes. Docstring coverage check skipped.

📜 Recent review details

Configuration used: CodeRabbit UI

Review profile: ASSERTIVE

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 2710543 and 2c26f5c.

⛔ Files ignored due to path filters (1)
  • frontend/package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (2)
  • frontend/package.json (1 hunks)
  • mkdocs.yml (1 hunks)

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting

Comment on lines 208 to 214
for (const journey of perfConfig.journeys) {
test(journey.id, async () => {
const runs: Array<{ navigation_duration_ms: number; largest_contentful_paint_ms: number }> = [];

for (let iteration = 0; iteration < runCount; iteration += 1) {
const metrics = await collectNavigationMetrics(browser, journey.from_url, journey.to_url, journey.waits);
runs.push({

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Inject Playwright browser fixture

Each generated test calls collectNavigationMetrics(browser, …) but browser is never defined in this spec, so Playwright will throw ReferenceError: browser is not defined before any metrics are gathered. The fixture must be pulled from the test context (e.g. test(journey.id, async ({ browser }) => …) or retrieved in a beforeAll) so the performance job can run at all.

Useful? React with 👍 / 👎.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 12

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
frontend/package.json (2)

34-34: Remove duplicate @playwright/test dependency.

The @playwright/test package appears twice in devDependencies at lines 34 and 57, both specifying version ^1.56.0. This duplication is likely unintentional and should be consolidated to a single entry.

Apply this diff to remove the duplicate:

     "@types/react-dom": "^18",
     "@types/js-yaml": "^4.0.9",
     "autoprefixer": "^10.0.1",
     "eslint": "^8",
     "eslint-config-next": "14.2.0",
     "gltfpack": "0.25.0",
     "jest": "^29.7.0",
     "jest-environment-jsdom": "^29.7.0",
     "meshoptimizer": "0.25.0",
     "postcss": "^8",
     "tailwindcss": "^3.3.0",
     "ts-jest": "^29.2.5",
     "typescript": "^5",
     "gltfpack": "0.25.0",
     "meshoptimizer": "0.25.0",
     "vitest": "^1.6.0",
-    "@playwright/test": "^1.56.0",
     "ts-node": "^10.9.2",
     "js-yaml": "^4.1.0"

Also applies to: 57-57


54-55: Remove duplicate gltfpack and meshoptimizer dependencies.

Both gltfpack and meshoptimizer appear twice in devDependencies at lines 46/54 and 49/55. Consolidate to single entries.

Apply this diff:

     "postcss": "^8",
     "tailwindcss": "^3.3.0",
     "ts-jest": "^29.2.5",
     "typescript": "^5",
-    "gltfpack": "0.25.0",
-    "meshoptimizer": "0.25.0",
     "vitest": "^1.6.0",
     "@playwright/test": "^1.56.0",
     "ts-node": "^10.9.2",
     "js-yaml": "^4.1.0"
📜 Review details

Configuration used: CodeRabbit UI

Review profile: ASSERTIVE

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between d2a35c4 and da2ecb7.

⛔ Files ignored due to path filters (1)
  • frontend/package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (9)
  • .github/workflows/ci.yml (1 hunks)
  • docs/release-checklist.md (1 hunks)
  • frontend/package.json (2 hunks)
  • frontend/tests/e2e/perf/perf-budget.spec.ts (1 hunks)
  • mkdocs.yml (1 hunks)
  • perf-budget.yml (1 hunks)
  • scripts/ci/__init__.py (1 hunks)
  • scripts/ci/check_canary_budgets.py (1 hunks)
  • scripts/publish-perf-metrics.mjs (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
frontend/tests/e2e/perf/perf-budget.spec.ts (2)
scripts/publish-perf-metrics.mjs (3)
  • entries (16-16)
  • metric (87-87)
  • metric (95-95)
backend/api/main.py (1)
  • metrics (105-107)
🪛 actionlint (1.7.8)
.github/workflows/ci.yml

155-155: could not parse as YAML: could not find expected ':'

(syntax-check)

🪛 Checkov (3.2.334)
.github/workflows/ci.yml

[low] 135-136: Base64 High Entropy String

(CKV_SECRET_6)

🪛 LanguageTool
docs/release-checklist.md

[grammar] ~1-~1: Use correct spacing
Context: # Release Checklist This checklist captures the operational ...

(QB_NEW_EN_OTHER_ERROR_IDS_5)


[grammar] ~3-~3: Use correct spacing
Context: ...have a consistent view of system health. ## 1. Verify CI performance budgets 1. Ins...

(QB_NEW_EN_OTHER_ERROR_IDS_5)


[grammar] ~5-~5: There might be a mistake here.
Context: ...th. ## 1. Verify CI performance budgets 1. Inspect the perf-budget job in the C...

(QB_NEW_EN_OTHER)


[grammar] ~9-~9: Use a hyphen to join words.
Context: ...remediation plan is documented. 3. Cross check the JUnit report (or Pushgateway m...

(QB_NEW_EN_HYPHEN)


[grammar] ~9-~9: Use correct spacing
Context: ...fore approving the release branch merge. ## 2. Review canary latency and error budge...

(QB_NEW_EN_OTHER_ERROR_IDS_5)


[grammar] ~11-~11: There might be a mistake here.
Context: ... Review canary latency and error budgets 1. Open the observability-budgets job l...

(QB_NEW_EN_OTHER)


[grammar] ~14-~14: Use correct spacing
Context: ...roll back before continuing the release. ## 3. Validate Grafana alerts and on-call n...

(QB_NEW_EN_OTHER_ERROR_IDS_5)


[grammar] ~16-~16: There might be a mistake here.
Context: ...Grafana alerts and on-call notifications 1. Check that the Grafana dashboard for the...

(QB_NEW_EN_OTHER)


[grammar] ~20-~20: Use correct spacing
Context: ...rage changed since the previous release. ## 4. Preview environment gating 1. Before...

(QB_NEW_EN_OTHER_ERROR_IDS_5)


[grammar] ~22-~22: Use correct spacing
Context: ...lease. ## 4. Preview environment gating 1. Before cutting a release tag, verify tha...

(QB_NEW_EN_OTHER_ERROR_IDS_5)


[grammar] ~25-~25: There might be a mistake here.
Context: ...ecks load within the CI-defined budgets. - Error budgets are green in Grafana/Prome...

(QB_NEW_EN)


[style] ~28-~28: ‘prior to’ might be wordy. Consider a shorter alternative.
Context: ... ticket to document the state of the UI prior to launch. ## 5. Final approval 1. Ensur...

(EN_WORDINESS_PREMIUM_PRIOR_TO)


[grammar] ~28-~28: Use correct spacing
Context: ...ent the state of the UI prior to launch. ## 5. Final approval 1. Ensure all checkli...

(QB_NEW_EN_OTHER_ERROR_IDS_5)


[grammar] ~30-~30: Use correct spacing
Context: ...I prior to launch. ## 5. Final approval 1. Ensure all checklist items above are mar...

(QB_NEW_EN_OTHER_ERROR_IDS_5)

🪛 Ruff (0.14.0)
scripts/ci/check_canary_budgets.py

49-49: Audit URL open for permitted schemes. Allowing use of file: or custom schemes is often unexpected.

(S310)


52-52: Within an except clause, raise exceptions with raise ... from err or raise ... from None to distinguish them from errors in exception handling

(B904)


52-52: Avoid specifying long messages outside the exception class

(TRY003)


55-55: Avoid specifying long messages outside the exception class

(TRY003)


59-59: Avoid specifying long messages outside the exception class

(TRY003)


63-63: Avoid specifying long messages outside the exception class

(TRY003)


106-108: Audit URL open for permitted schemes. Allowing use of file: or custom schemes is often unexpected.

(S310)


110-110: Audit URL open for permitted schemes. Allowing use of file: or custom schemes is often unexpected.

(S310)


113-113: Within an except clause, raise exceptions with raise ... from err or raise ... from None to distinguish them from errors in exception handling

(B904)


113-113: Avoid specifying long messages outside the exception class

(TRY003)


120-120: Avoid specifying long messages outside the exception class

(TRY003)


124-124: Avoid specifying long messages outside the exception class

(TRY003)

🪛 YAMLlint (1.37.1)
.github/workflows/ci.yml

[error] 156-156: syntax error: could not find expected ':'

(syntax)

🔇 Additional comments (20)
scripts/ci/__init__.py (1)

1-1: LGTM!

The module docstring clearly describes the purpose of the CI helper scripts package and aligns with the new observability tooling introduced in this PR.

scripts/ci/check_canary_budgets.py (5)

16-36: LGTM!

The BudgetResult dataclass is well-designed with clear fields and a useful to_line method for human-readable output. The optional fields are handled correctly with conditional formatting.


43-66: Function logic is correct with proper error handling.

The Prometheus query implementation correctly constructs the API request, handles timeouts, and validates the response structure. The static analysis warnings about URL schemes (S310) are false positives in this CI context where URLs are controlled by environment configuration.


68-102: LGTM!

The budget evaluation logic correctly:

  • Queries both current and baseline values
  • Applies unit scaling
  • Computes regression ratio with proper zero-division guard (line 86)
  • Evaluates pass/fail based on threshold and regression tolerance

The handling of previous_value as a falsy check correctly prevents division by zero when the previous value is 0.0.


104-135: LGTM!

The Tempo query implementation correctly:

  • Posts JSON query to the search API
  • Validates response structure and trace data
  • Extracts duration values with type checking (line 122)
  • Returns the maximum trace duration for budget comparison

Error handling appropriately exits on missing or invalid data.


137-198: LGTM!

The main function correctly:

  • Reads configuration from environment with sensible defaults
  • Gracefully skips validation when Prometheus config is missing (lines 142-144)
  • Evaluates both required budgets (latency, error rate)
  • Conditionally includes Tempo evaluation when configured
  • Aggregates results and exits with appropriate status code

The early return when configuration is missing (line 144) prevents false failures in environments where observability isn't configured.

mkdocs.yml (1)

36-36: LGTM!

The addition of the Release Checklist to the documentation navigation is well-positioned in the logical flow, appearing after introductory content and before deployment details.

frontend/package.json (1)

42-42: LGTM!

The additions of js-yaml and @types/js-yaml are appropriate for parsing the perf-budget.yml configuration in the new performance test suite. The version 4.1.0 is the latest stable release with safe-by-default behavior.

Based on learnings.

Also applies to: 59-59

perf-budget.yml (1)

44-61: LGTM!

The new journey configuration is well-structured:

  • Clear navigation path from homepage to configurator
  • Appropriate wait condition for canvas element with reasonable timeout
  • Meaningful metrics (navigation duration, LCP) with percentile-based aggregation
  • Reasonable thresholds: 3s for P90 navigation, 4s for P95 LCP

The configuration aligns with the new perf-budget test suite and CI workflow.

.github/workflows/ci.yml (4)

106-149: LGTM!

The perf-budget job setup correctly:

  • Sequences after test jobs to ensure baseline quality
  • Installs required dependencies (Node, Playwright with system deps)
  • Creates appropriate development environment configuration
  • Starts the Docker stack and polls for service readiness with timeouts
  • Uses reasonable timeout values (60 iterations × 2s = 2 min max wait)

The Checkov warning about line 135-136 is a false positive—the value is a placeholder string, not an actual secret.


150-194: LGTM!

The inline Python seeding script correctly:

  • Uses proper heredoc syntax for embedding Python in YAML (python - <<'PY')
  • Creates test fixtures (materials, modules) via API calls
  • Includes appropriate error handling with detailed failure messages
  • Validates responses and extracts required IDs for dependent resources

The static analysis syntax errors are false positives—the actionlint and yamllint tools misparse the heredoc delimiter inside the YAML run block. The syntax is valid for GitHub Actions.


196-228: LGTM!

The test execution and cleanup steps correctly:

  • Run the specific performance budget test suite
  • Configure multiple reporters (junit, line) for visibility
  • Publish metrics conditionally using environment-driven configuration
  • Upload artifacts regardless of test outcome (if: always())
  • Ensure Docker stack cleanup runs even on failure

The workflow properly handles both Pushgateway-based metric publishing and JUnit report generation as fallback.


229-249: LGTM!

The observability-budgets job correctly:

  • Sequences after perf-budget to validate metrics
  • Uses secrets for sensitive endpoint URLs (Prometheus, Tempo)
  • Uses repository variables for queries (appropriate for non-sensitive config)
  • Provides sensible default thresholds (3s latency, 2% error rate, 15% regression tolerance)
  • Executes the budget validation script with proper environment setup

The separation of secrets vs. variables is appropriate for security and maintainability.

docs/release-checklist.md (1)

1-34: LGTM!

The release checklist provides comprehensive operational guidance that ties together:

  • CI performance budget validation (perf-budget job)
  • Canary budget evaluation (observability-budgets job)
  • Alert configuration and on-call routing
  • Preview environment gating
  • Approval workflow

The structure is clear and actionable, providing specific steps for each gate. The checklist effectively documents the release governance process introduced by this PR's observability infrastructure.

scripts/publish-perf-metrics.mjs (2)

65-80: LGTM!

The Pushgateway integration correctly:

  • URL-encodes job and journey labels to prevent injection (line 69)
  • Posts to the correct endpoint with proper content type
  • Validates response status and includes error details
  • Processes metrics for each journey independently

135-154: LGTM!

The main flow correctly:

  • Reads journey metrics from the results directory
  • Gracefully skips when no results are found (lines 142-144)
  • Conditionally publishes to Pushgateway or generates JUnit based on configuration
  • Provides clear logging for each outcome

The error handling (lines 156-159) appropriately sets a non-zero exit code for failures.

frontend/tests/e2e/perf/perf-budget.spec.ts (4)

1-5: LGTM!

Imports are appropriate for the performance testing requirements.


7-53: LGTM!

Type definitions are well-structured and provide clear contracts for configuration and results.


55-68: LGTM!

The percentile calculation correctly implements linear interpolation between sorted values, following standard statistical methods.


177-189: Ignore LCP extraction change
entry.startTime already returns the correct LCP time (it yields renderTime if non-zero, otherwise loadTime).

Likely an incorrect or invalid review comment.

Comment on lines +70 to +77
const loadPerfBudget = (): PerfBudgetConfig => {
const configPath = process.env.PERF_BUDGET_FILE
? path.resolve(process.cwd(), process.env.PERF_BUDGET_FILE)
: path.resolve(__dirname, '../../../../perf-budget.yml');

const raw = fs.readFileSync(configPath, 'utf8');
return yaml.load(raw) as PerfBudgetConfig;
};
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

🧩 Analysis chain

Add error handling for file operations and config validation.

The function lacks error handling for:

  • Missing or unreadable config files
  • YAML parsing errors
  • Invalid config structure after loading

Additionally, __dirname usage on line 73 may not be available in ES module contexts or certain TypeScript configurations.

Apply this diff to add error handling:

 const loadPerfBudget = (): PerfBudgetConfig => {
   const configPath = process.env.PERF_BUDGET_FILE
     ? path.resolve(process.cwd(), process.env.PERF_BUDGET_FILE)
     : path.resolve(__dirname, '../../../../perf-budget.yml');
 
+  if (!fs.existsSync(configPath)) {
+    throw new Error(`Performance budget config not found at: ${configPath}`);
+  }
+
+  try {
     const raw = fs.readFileSync(configPath, 'utf8');
-    return yaml.load(raw) as PerfBudgetConfig;
+    const config = yaml.load(raw) as PerfBudgetConfig;
+    
+    if (!config || typeof config !== 'object') {
+      throw new Error('Invalid config structure');
+    }
+    
+    return config;
+  } catch (error) {
+    throw new Error(`Failed to load perf budget config: ${error instanceof Error ? error.message : String(error)}`);
+  }
 };

Run the following script to verify if __dirname is available in the TypeScript/build configuration:


🏁 Script executed:

#!/bin/bash
# Check if the project uses ES modules and how __dirname is handled

# Check package.json for module type
echo "=== Checking package.json for module type ==="
fd -t f '^package\.json$' frontend | head -1 | xargs cat | jq -r '.type // "commonjs"'

# Check tsconfig.json for module settings
echo -e "\n=== Checking TypeScript module configuration ==="
fd -t f '^tsconfig.*\.json$' frontend | head -3 | xargs -I {} sh -c 'echo "File: {}" && cat {} | jq -r ".compilerOptions.module // \"not set\""'

# Search for other usages of __dirname in test files
echo -e "\n=== Other __dirname usages in test files ==="
rg -t ts '__dirname' frontend/tests/ -C 2

Length of output: 1122


Optional: Add error handling and validation in loadPerfBudget.

The loader may throw on missing files or invalid YAML—wrapping readFileSync and yaml.load in a try/catch and validating the parsed object can provide clearer failure messages.

🤖 Prompt for AI Agents
In frontend/tests/e2e/perf/perf-budget.spec.ts around lines 70 to 77, wrap the
file read and YAML parsing in a try/catch to surface a clear, descriptive error
when the PERF_BUDGET_FILE is missing or the YAML is invalid; before reading,
optionally check fs.existsSync(configPath) and throw a clear error if missing,
then call fs.readFileSync and yaml.load inside the try block, validate the
returned object matches the expected PerfBudgetConfig shape (required
fields/types) and throw a descriptive error if validation fails, and rethrow or
wrap parsing/file I/O errors with contextual messages so test failures indicate
exactly whether the file was missing, unreadable, or the YAML invalid.

Comment on lines +79 to +106
const toQuantile = (aggregation: string): number | undefined => {
const match = /^p(\d{1,2})$/i.exec(aggregation.trim());
if (!match) {
return undefined;
}
const percentileNumber = Number(match[1]);
return percentileNumber / 100;
};

const ensureResultsDir = (): string => {
const dir = process.env.PERF_RESULTS_DIR
? path.resolve(process.cwd(), process.env.PERF_RESULTS_DIR)
: path.resolve(__dirname, '../../../../perf-results');
fs.mkdirSync(dir, { recursive: true });
return dir;
};

const writeJourneyResult = (dir: string, result: JourneyResult): void => {
const filePath = path.join(dir, `${result.id}.json`);
fs.writeFileSync(filePath, JSON.stringify(result, null, 2), 'utf8');
};

const attachJourneyResult = async (result: JourneyResult): Promise<void> => {
await test.info().attach(`${result.id}-metrics.json`, {
body: Buffer.from(JSON.stringify(result, null, 2)),
contentType: 'application/json',
});
};
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Consider adding error handling to file write operations.

The writeJourneyResult function (lines 96-99) lacks error handling for file write failures. While not critical for test execution, it could cause silent failures in results persistence.

Apply this diff to add error handling:

 const writeJourneyResult = (dir: string, result: JourneyResult): void => {
   const filePath = path.join(dir, `${result.id}.json`);
-  fs.writeFileSync(filePath, JSON.stringify(result, null, 2), 'utf8');
+  try {
+    fs.writeFileSync(filePath, JSON.stringify(result, null, 2), 'utf8');
+  } catch (error) {
+    console.warn(`Failed to write journey result to ${filePath}:`, error);
+  }
 };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const toQuantile = (aggregation: string): number | undefined => {
const match = /^p(\d{1,2})$/i.exec(aggregation.trim());
if (!match) {
return undefined;
}
const percentileNumber = Number(match[1]);
return percentileNumber / 100;
};
const ensureResultsDir = (): string => {
const dir = process.env.PERF_RESULTS_DIR
? path.resolve(process.cwd(), process.env.PERF_RESULTS_DIR)
: path.resolve(__dirname, '../../../../perf-results');
fs.mkdirSync(dir, { recursive: true });
return dir;
};
const writeJourneyResult = (dir: string, result: JourneyResult): void => {
const filePath = path.join(dir, `${result.id}.json`);
fs.writeFileSync(filePath, JSON.stringify(result, null, 2), 'utf8');
};
const attachJourneyResult = async (result: JourneyResult): Promise<void> => {
await test.info().attach(`${result.id}-metrics.json`, {
body: Buffer.from(JSON.stringify(result, null, 2)),
contentType: 'application/json',
});
};
const writeJourneyResult = (dir: string, result: JourneyResult): void => {
const filePath = path.join(dir, `${result.id}.json`);
try {
fs.writeFileSync(filePath, JSON.stringify(result, null, 2), 'utf8');
} catch (error) {
console.warn(`Failed to write journey result to ${filePath}:`, error);
}
};
🤖 Prompt for AI Agents
In frontend/tests/e2e/perf/perf-budget.spec.ts around lines 79 to 106, the
writeJourneyResult function (lines 96-99) writes to disk without error handling;
wrap the fs.writeFileSync call in a try/catch, build the same filePath before
writing, and on failure log a clear error (including filePath and the caught
error) and attach the error/details to the current test via test.info().attach
so the failure is recorded; do not let the exception crash the test run (do not
rethrow) unless you intentionally want the test to fail.

Comment on lines +108 to +194
const collectNavigationMetrics = async (
browser: Browser,
fromUrl: string,
toUrl: string,
waits: WaitConfig[] | undefined,
): Promise<{ navigation: number; lcp: number }> => {
const context = await browser.newContext();

await context.addInitScript(() => {
(window as typeof window & {
__lcpEntries?: PerformanceEntry[];
__lcpObserver?: PerformanceObserver;
}).__lcpEntries = [];
});

const page = await context.newPage();
await page.goto(fromUrl, { waitUntil: 'networkidle' });

await page.evaluate(() => {
const globalWindow = window as typeof window & {
__lcpEntries?: PerformanceEntry[];
__lcpObserver?: PerformanceObserver;
};

globalWindow.__lcpEntries = [];
if (globalWindow.__lcpObserver) {
globalWindow.__lcpObserver.disconnect();
}
globalWindow.__lcpObserver = new PerformanceObserver((entryList) => {
const entries = entryList.getEntries();
globalWindow.__lcpEntries = [
...(globalWindow.__lcpEntries ?? []),
...entries,
];
});
globalWindow.__lcpObserver.observe({ type: 'largest-contentful-paint', buffered: true });

performance.clearResourceTimings();
performance.clearMeasures();
performance.clearMarks();
});

await page.goto(toUrl, { waitUntil: 'networkidle' });

if (waits) {
for (const wait of waits) {
if (wait.type === 'selector' && wait.selector) {
await page.waitForSelector(wait.selector, {
state: 'attached',
timeout: wait.timeout_ms ?? 30_000,
});
}
if (wait.type === 'networkidle') {
await page.waitForLoadState('networkidle', { timeout: wait.timeout_ms ?? 30_000 });
if (wait.idle_ms) {
await page.waitForTimeout(wait.idle_ms);
}
}
}
}

await page.waitForTimeout(500);

const navigationDuration = await page.evaluate(() => {
const entries = performance.getEntriesByType('navigation');
const last = entries[entries.length - 1] as PerformanceNavigationTiming | undefined;
return last ? last.duration : Number.NaN;
});

const lcp = await page.evaluate(() => {
const globalWindow = window as typeof window & {
__lcpEntries?: PerformanceEntry[];
};
const entries = (globalWindow.__lcpEntries ?? []) as Array<PerformanceEntry & { renderTime?: number; loadTime?: number; startTime: number }>;
if (!entries.length) {
return Number.NaN;
}
return entries.reduce((acc, entry) => {
const candidate = entry.startTime ?? entry.renderTime ?? entry.loadTime ?? 0;
return Math.max(acc, candidate);
}, 0);
});

await context.close();

return { navigation: navigationDuration, lcp };
};
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Add resource cleanup in error scenarios.

The function creates a browser context (line 114) but doesn't guarantee cleanup if an error occurs between context creation and the close call (line 191). This could lead to resource leaks in test failures.

Apply this diff to ensure cleanup:

 ): Promise<{ navigation: number; lcp: number }> => {
   const context = await browser.newContext();
+  
+  try {
+    await context.addInitScript(() => {
+      (window as typeof window & {
+        __lcpEntries?: PerformanceEntry[];
+        __lcpObserver?: PerformanceObserver;
+      }).__lcpEntries = [];
+    });

-  await context.addInitScript(() => {
-    (window as typeof window & {
-      __lcpEntries?: PerformanceEntry[];
-      __lcpObserver?: PerformanceObserver;
-    }).__lcpEntries = [];
-  });
-
-  const page = await context.newPage();
+    const page = await context.newPage();
   
   // ... rest of the function logic ...
   
-  await context.close();
-
-  return { navigation: navigationDuration, lcp };
+    return { navigation: navigationDuration, lcp };
+  } finally {
+    await context.close();
+  }
 };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const collectNavigationMetrics = async (
browser: Browser,
fromUrl: string,
toUrl: string,
waits: WaitConfig[] | undefined,
): Promise<{ navigation: number; lcp: number }> => {
const context = await browser.newContext();
await context.addInitScript(() => {
(window as typeof window & {
__lcpEntries?: PerformanceEntry[];
__lcpObserver?: PerformanceObserver;
}).__lcpEntries = [];
});
const page = await context.newPage();
await page.goto(fromUrl, { waitUntil: 'networkidle' });
await page.evaluate(() => {
const globalWindow = window as typeof window & {
__lcpEntries?: PerformanceEntry[];
__lcpObserver?: PerformanceObserver;
};
globalWindow.__lcpEntries = [];
if (globalWindow.__lcpObserver) {
globalWindow.__lcpObserver.disconnect();
}
globalWindow.__lcpObserver = new PerformanceObserver((entryList) => {
const entries = entryList.getEntries();
globalWindow.__lcpEntries = [
...(globalWindow.__lcpEntries ?? []),
...entries,
];
});
globalWindow.__lcpObserver.observe({ type: 'largest-contentful-paint', buffered: true });
performance.clearResourceTimings();
performance.clearMeasures();
performance.clearMarks();
});
await page.goto(toUrl, { waitUntil: 'networkidle' });
if (waits) {
for (const wait of waits) {
if (wait.type === 'selector' && wait.selector) {
await page.waitForSelector(wait.selector, {
state: 'attached',
timeout: wait.timeout_ms ?? 30_000,
});
}
if (wait.type === 'networkidle') {
await page.waitForLoadState('networkidle', { timeout: wait.timeout_ms ?? 30_000 });
if (wait.idle_ms) {
await page.waitForTimeout(wait.idle_ms);
}
}
}
}
await page.waitForTimeout(500);
const navigationDuration = await page.evaluate(() => {
const entries = performance.getEntriesByType('navigation');
const last = entries[entries.length - 1] as PerformanceNavigationTiming | undefined;
return last ? last.duration : Number.NaN;
});
const lcp = await page.evaluate(() => {
const globalWindow = window as typeof window & {
__lcpEntries?: PerformanceEntry[];
};
const entries = (globalWindow.__lcpEntries ?? []) as Array<PerformanceEntry & { renderTime?: number; loadTime?: number; startTime: number }>;
if (!entries.length) {
return Number.NaN;
}
return entries.reduce((acc, entry) => {
const candidate = entry.startTime ?? entry.renderTime ?? entry.loadTime ?? 0;
return Math.max(acc, candidate);
}, 0);
});
await context.close();
return { navigation: navigationDuration, lcp };
};
const collectNavigationMetrics = async (
browser: Browser,
fromUrl: string,
toUrl: string,
waits: WaitConfig[] | undefined,
): Promise<{ navigation: number; lcp: number }> => {
const context = await browser.newContext();
try {
await context.addInitScript(() => {
(window as typeof window & {
__lcpEntries?: PerformanceEntry[];
__lcpObserver?: PerformanceObserver;
}).__lcpEntries = [];
});
const page = await context.newPage();
await page.goto(fromUrl, { waitUntil: 'networkidle' });
await page.evaluate(() => {
const globalWindow = window as typeof window & {
__lcpEntries?: PerformanceEntry[];
__lcpObserver?: PerformanceObserver;
};
globalWindow.__lcpEntries = [];
if (globalWindow.__lcpObserver) {
globalWindow.__lcpObserver.disconnect();
}
globalWindow.__lcpObserver = new PerformanceObserver((entryList) => {
const entries = entryList.getEntries();
globalWindow.__lcpEntries = [
...(globalWindow.__lcpEntries ?? []),
...entries,
];
});
globalWindow.__lcpObserver.observe({ type: 'largest-contentful-paint', buffered: true });
performance.clearResourceTimings();
performance.clearMeasures();
performance.clearMarks();
});
await page.goto(toUrl, { waitUntil: 'networkidle' });
if (waits) {
for (const wait of waits) {
if (wait.type === 'selector' && wait.selector) {
await page.waitForSelector(wait.selector, {
state: 'attached',
timeout: wait.timeout_ms ?? 30_000,
});
}
if (wait.type === 'networkidle') {
await page.waitForLoadState('networkidle', { timeout: wait.timeout_ms ?? 30_000 });
if (wait.idle_ms) {
await page.waitForTimeout(wait.idle_ms);
}
}
}
}
await page.waitForTimeout(500);
const navigationDuration = await page.evaluate(() => {
const entries = performance.getEntriesByType('navigation');
const last = entries[entries.length - 1] as PerformanceNavigationTiming | undefined;
return last ? last.duration : Number.NaN;
});
const lcp = await page.evaluate(() => {
const globalWindow = window as typeof window & {
__lcpEntries?: PerformanceEntry[];
};
const entries = (globalWindow.__lcpEntries ?? []) as Array<
PerformanceEntry & { renderTime?: number; loadTime?: number; startTime: number }
>;
if (!entries.length) {
return Number.NaN;
}
return entries.reduce((acc, entry) => {
const candidate = entry.startTime ?? entry.renderTime ?? entry.loadTime ?? 0;
return Math.max(acc, candidate);
}, 0);
});
return { navigation: navigationDuration, lcp };
} finally {
await context.close();
}
};
🤖 Prompt for AI Agents
In frontend/tests/e2e/perf/perf-budget.spec.ts around lines 108 to 194, the
function creates a browser context at line 114 but only calls context.close() at
the end, so if an exception is thrown before line 191 the context (and page) may
never be closed; to fix this, allocate the context (and page) to variables
scoped outside the try, then wrap the navigation/measurement logic in try { ...
} finally { if (page) await page.close().catch(() => {}); if (context) await
context.close().catch(() => {}); } so the context and page are always cleaned up
even on errors.

}
}

await page.waitForTimeout(500);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Document or make the fixed delay configurable.

Line 169 introduces an arbitrary 500ms wait after processing configured waits. This magic number lacks explanation for why this specific duration is needed.

Consider either:

  1. Adding a comment explaining why this delay is necessary, or
  2. Making it configurable via the journey configuration, or
  3. Removing it if it's not essential
+  // Brief delay to ensure all performance entries are captured
   await page.waitForTimeout(500);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
await page.waitForTimeout(500);
// Brief delay to ensure all performance entries are captured
await page.waitForTimeout(500);
🤖 Prompt for AI Agents
In frontend/tests/e2e/perf/perf-budget.spec.ts around line 169, there is a
hardcoded 500ms wait (await page.waitForTimeout(500)) acting as an unexplained
"magic" delay; replace or document it by either (A) adding a brief comment that
explains the purpose and why 500ms was chosen, (B) exposing the delay as a
configurable value in the journey/test config (e.g., a named timeout like
postWaitDelayMs) and using that config value here, or (C) remove the wait
entirely if verification shows it is unnecessary; implement the chosen option
and update any related tests or config defaults accordingly.

Comment on lines +242 to +252
if (lcpValues.length) {
const metricConfig = metricConfigById.get('largest-contentful-paint');
const quantile = metricConfig ? toQuantile(metricConfig.aggregation) : 0.95;
const value = percentile(lcpValues, quantile ?? 0.95);
aggregated.largest_contentful_paint = {
quantile: metricConfig ? metricConfig.aggregation.toLowerCase() : 'p95',
value_ms: value,
threshold_ms: metricConfig?.threshold,
passed: metricConfig ? value <= metricConfig.threshold : true,
};
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Critical: Property naming inconsistency with downstream script.

Line 246 uses largest_contentful_paint (with underscores) as the property name, but the publish metrics script at scripts/publish-perf-metrics.mjs line 94 expects largest-contentful-paint (with hyphens), causing the downstream script to receive undefined values.

Apply this diff to maintain consistency with the expected format:

       if (lcpValues.length) {
         const metricConfig = metricConfigById.get('largest-contentful-paint');
         const quantile = metricConfig ? toQuantile(metricConfig.aggregation) : 0.95;
         const value = percentile(lcpValues, quantile ?? 0.95);
-        aggregated.largest_contentful_paint = {
+        aggregated['largest-contentful-paint'] = {
           quantile: metricConfig ? metricConfig.aggregation.toLowerCase() : 'p95',
           value_ms: value,
           threshold_ms: metricConfig?.threshold,
           passed: metricConfig ? value <= metricConfig.threshold : true,
         };
       }

Also update the type definition to match:

 type JourneyResult = {
   id: string;
   description?: string;
   runs: Array<{
     navigation_duration_ms: number;
     largest_contentful_paint_ms: number;
   }>;
   aggregated: {
     navigation_duration?: JourneyMetricSummary;
-    largest_contentful_paint?: JourneyMetricSummary;
+    'largest-contentful-paint'?: JourneyMetricSummary;
   };
 };

And update line 270:

-      if (aggregated.largest_contentful_paint && aggregated.largest_contentful_paint.threshold_ms !== undefined) {
+      if (aggregated['largest-contentful-paint'] && aggregated['largest-contentful-paint'].threshold_ms !== undefined) {
-        expect(aggregated.largest_contentful_paint.value_ms, 'homepage to configurator LCP P95 should remain under budget').toBeLessThanOrEqual(
+        expect(aggregated['largest-contentful-paint'].value_ms, 'homepage to configurator LCP P95 should remain under budget').toBeLessThanOrEqual(
-          aggregated.largest_contentful_paint.threshold_ms,
+          aggregated['largest-contentful-paint'].threshold_ms,
         );
       }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (lcpValues.length) {
const metricConfig = metricConfigById.get('largest-contentful-paint');
const quantile = metricConfig ? toQuantile(metricConfig.aggregation) : 0.95;
const value = percentile(lcpValues, quantile ?? 0.95);
aggregated.largest_contentful_paint = {
quantile: metricConfig ? metricConfig.aggregation.toLowerCase() : 'p95',
value_ms: value,
threshold_ms: metricConfig?.threshold,
passed: metricConfig ? value <= metricConfig.threshold : true,
};
}
if (lcpValues.length) {
const metricConfig = metricConfigById.get('largest-contentful-paint');
const quantile = metricConfig ? toQuantile(metricConfig.aggregation) : 0.95;
const value = percentile(lcpValues, quantile ?? 0.95);
aggregated['largest-contentful-paint'] = {
quantile: metricConfig ? metricConfig.aggregation.toLowerCase() : 'p95',
value_ms: value,
threshold_ms: metricConfig?.threshold,
passed: metricConfig ? value <= metricConfig.threshold : true,
};
}
Suggested change
if (lcpValues.length) {
const metricConfig = metricConfigById.get('largest-contentful-paint');
const quantile = metricConfig ? toQuantile(metricConfig.aggregation) : 0.95;
const value = percentile(lcpValues, quantile ?? 0.95);
aggregated.largest_contentful_paint = {
quantile: metricConfig ? metricConfig.aggregation.toLowerCase() : 'p95',
value_ms: value,
threshold_ms: metricConfig?.threshold,
passed: metricConfig ? value <= metricConfig.threshold : true,
};
}
type JourneyResult = {
id: string;
description?: string;
runs: Array<{
navigation_duration_ms: number;
largest_contentful_paint_ms: number;
}>;
aggregated: {
navigation_duration?: JourneyMetricSummary;
'largest-contentful-paint'?: JourneyMetricSummary;
};
};
Suggested change
if (lcpValues.length) {
const metricConfig = metricConfigById.get('largest-contentful-paint');
const quantile = metricConfig ? toQuantile(metricConfig.aggregation) : 0.95;
const value = percentile(lcpValues, quantile ?? 0.95);
aggregated.largest_contentful_paint = {
quantile: metricConfig ? metricConfig.aggregation.toLowerCase() : 'p95',
value_ms: value,
threshold_ms: metricConfig?.threshold,
passed: metricConfig ? value <= metricConfig.threshold : true,
};
}
if (aggregated['largest-contentful-paint'] && aggregated['largest-contentful-paint'].threshold_ms !== undefined) {
expect(
aggregated['largest-contentful-paint'].value_ms,
'homepage to configurator LCP P95 should remain under budget'
).toBeLessThanOrEqual(
aggregated['largest-contentful-paint'].threshold_ms,
);
}
🤖 Prompt for AI Agents
In frontend/tests/e2e/perf/perf-budget.spec.ts around lines 242 to 252, the test
populates aggregated.largest_contentful_paint (underscores) but downstream
scripts expect the hyphenated key 'largest-contentful-paint'; change the
property name to use 'largest-contentful-paint' when assigning the aggregated
metric, update the corresponding test/type definition so the aggregated object
type includes 'largest-contentful-paint' (string key with hyphens) instead of
largest_contentful_paint, and adjust the reference at line 270 to read the
hyphenated key as well so the test produces the same shape expected by
scripts/publish-perf-metrics.mjs.

Comment on lines +264 to +274
if (aggregated.navigation_duration && aggregated.navigation_duration.threshold_ms !== undefined) {
expect(aggregated.navigation_duration.value_ms, 'homepage to configurator navigation P90 should remain under budget').toBeLessThanOrEqual(
aggregated.navigation_duration.threshold_ms,
);
}

if (aggregated.largest_contentful_paint && aggregated.largest_contentful_paint.threshold_ms !== undefined) {
expect(aggregated.largest_contentful_paint.value_ms, 'homepage to configurator LCP P95 should remain under budget').toBeLessThanOrEqual(
aggregated.largest_contentful_paint.threshold_ms,
);
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Fix hardcoded assertion messages to reflect actual journey.

Lines 265 and 271 contain hardcoded assertion messages referring to "homepage to configurator" but this test runs for all journeys in a loop. The messages should dynamically reflect the actual journey being tested.

Apply this diff to use dynamic messages:

       if (aggregated.navigation_duration && aggregated.navigation_duration.threshold_ms !== undefined) {
-        expect(aggregated.navigation_duration.value_ms, 'homepage to configurator navigation P90 should remain under budget').toBeLessThanOrEqual(
+        expect(aggregated.navigation_duration.value_ms, `${journey.id} navigation ${aggregated.navigation_duration.quantile.toUpperCase()} should remain under budget`).toBeLessThanOrEqual(
           aggregated.navigation_duration.threshold_ms,
         );
       }
 
       if (aggregated.largest_contentful_paint && aggregated.largest_contentful_paint.threshold_ms !== undefined) {
-        expect(aggregated.largest_contentful_paint.value_ms, 'homepage to configurator LCP P95 should remain under budget').toBeLessThanOrEqual(
+        expect(aggregated.largest_contentful_paint.value_ms, `${journey.id} LCP ${aggregated.largest_contentful_paint.quantile.toUpperCase()} should remain under budget`).toBeLessThanOrEqual(
           aggregated.largest_contentful_paint.threshold_ms,
         );
       }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (aggregated.navigation_duration && aggregated.navigation_duration.threshold_ms !== undefined) {
expect(aggregated.navigation_duration.value_ms, 'homepage to configurator navigation P90 should remain under budget').toBeLessThanOrEqual(
aggregated.navigation_duration.threshold_ms,
);
}
if (aggregated.largest_contentful_paint && aggregated.largest_contentful_paint.threshold_ms !== undefined) {
expect(aggregated.largest_contentful_paint.value_ms, 'homepage to configurator LCP P95 should remain under budget').toBeLessThanOrEqual(
aggregated.largest_contentful_paint.threshold_ms,
);
}
if (aggregated.navigation_duration && aggregated.navigation_duration.threshold_ms !== undefined) {
expect(
aggregated.navigation_duration.value_ms,
`${journey.id} navigation ${aggregated.navigation_duration.quantile.toUpperCase()} should remain under budget`
).toBeLessThanOrEqual(
aggregated.navigation_duration.threshold_ms,
);
}
if (aggregated.largest_contentful_paint && aggregated.largest_contentful_paint.threshold_ms !== undefined) {
expect(
aggregated.largest_contentful_paint.value_ms,
`${journey.id} LCP ${aggregated.largest_contentful_paint.quantile.toUpperCase()} should remain under budget`
).toBeLessThanOrEqual(
aggregated.largest_contentful_paint.threshold_ms,
);
}
🤖 Prompt for AI Agents
In frontend/tests/e2e/perf/perf-budget.spec.ts around lines 264 to 274, the
assertion messages are hardcoded as "homepage to configurator" but this test
runs for multiple journeys; replace those hardcoded strings with a dynamic
message that includes the current journey identifier (for example using the loop
variable like journey.name or journeyId) so the expect messages read something
like `${journey.name} navigation P90 should remain under budget` and
`${journey.name} LCP P95 should remain under budget`; update both expect calls
to use the dynamic journey-based message variable.

Comment on lines +14 to +31
const readJourneyFiles = async () => {
try {
const entries = await fs.readdir(RESULTS_DIR);
const jsonFiles = entries.filter((entry) => entry.endsWith('.json'));
const payloads = [];
for (const file of jsonFiles) {
const content = await fs.readFile(path.join(RESULTS_DIR, file), 'utf8');
payloads.push(JSON.parse(content));
}
return payloads;
} catch (error) {
if ((error instanceof Error && 'code' in error && error.code === 'ENOENT') ||
(typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT')) {
return [];
}
throw error;
}
};
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Simplify error handling for ENOENT.

The error handling at lines 25-26 has redundant checks. Modern Node.js errors from fs operations are always Error instances with a code property, so the complex typeof checks are unnecessary.

Apply this diff to simplify:

   } catch (error) {
-    if ((error instanceof Error && 'code' in error && error.code === 'ENOENT') ||
-        (typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT')) {
+    if (error?.code === 'ENOENT') {
       return [];
     }
     throw error;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const readJourneyFiles = async () => {
try {
const entries = await fs.readdir(RESULTS_DIR);
const jsonFiles = entries.filter((entry) => entry.endsWith('.json'));
const payloads = [];
for (const file of jsonFiles) {
const content = await fs.readFile(path.join(RESULTS_DIR, file), 'utf8');
payloads.push(JSON.parse(content));
}
return payloads;
} catch (error) {
if ((error instanceof Error && 'code' in error && error.code === 'ENOENT') ||
(typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT')) {
return [];
}
throw error;
}
};
const readJourneyFiles = async () => {
try {
const entries = await fs.readdir(RESULTS_DIR);
const jsonFiles = entries.filter((entry) => entry.endsWith('.json'));
const payloads = [];
for (const file of jsonFiles) {
const content = await fs.readFile(path.join(RESULTS_DIR, file), 'utf8');
payloads.push(JSON.parse(content));
}
return payloads;
} catch (error) {
if (error?.code === 'ENOENT') {
return [];
}
throw error;
}
};
🤖 Prompt for AI Agents
In scripts/publish-perf-metrics.mjs around lines 14–31, the catch block uses
redundant runtime type checks to detect ENOENT; simplify the conditional to a
single check for the code property (e.g. if (error.code === 'ENOENT') return
[]), removing the unnecessary instanceof/typeof branches so the catch returns an
empty array on missing directory and rethrows otherwise.

Comment on lines +33 to +63
const formatPrometheusBody = (journey) => {
const labelsBase = `journey="${journey.id}"`;
const lines = [
'# TYPE ci_navigation_duration_seconds summary',
];

if (journey.aggregated?.navigation_duration?.value_ms !== undefined) {
const navigationSeconds = journey.aggregated.navigation_duration.value_ms / 1000;
const quantileLabel = journey.aggregated.navigation_duration.quantile;
lines.push(`ci_navigation_duration_seconds{${labelsBase},quantile="${quantileLabel}"} ${navigationSeconds}`);
}

if (journey.aggregated?.largest_contentful_paint?.value_ms !== undefined) {
const lcpSeconds = journey.aggregated.largest_contentful_paint.value_ms / 1000;
const quantileLabel = journey.aggregated.largest_contentful_paint.quantile;
lines.push(`ci_lcp_seconds{${labelsBase},quantile="${quantileLabel}"} ${lcpSeconds}`);
}

const metaLabels = [`journey="${journey.id}"`];
if (GIT_SHA) {
metaLabels.push(`git_sha="${GIT_SHA}"`);
}
if (GIT_REF) {
metaLabels.push(`git_ref="${GIT_REF}"`);
}

lines.push(`# TYPE ci_perf_build_info gauge`);
lines.push(`ci_perf_build_info{${metaLabels.join(',')}} 1`);

return lines.join('\n');
};
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Consider adding trailing newline for Prometheus exposition format.

While the current implementation is functional, the Prometheus exposition format typically expects a trailing newline. This ensures compatibility with all parser implementations.

Apply this diff:

   lines.push(`# TYPE ci_perf_build_info gauge`);
   lines.push(`ci_perf_build_info{${metaLabels.join(',')}} 1`);
 
-  return lines.join('\n');
+  return lines.join('\n') + '\n';
 };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const formatPrometheusBody = (journey) => {
const labelsBase = `journey="${journey.id}"`;
const lines = [
'# TYPE ci_navigation_duration_seconds summary',
];
if (journey.aggregated?.navigation_duration?.value_ms !== undefined) {
const navigationSeconds = journey.aggregated.navigation_duration.value_ms / 1000;
const quantileLabel = journey.aggregated.navigation_duration.quantile;
lines.push(`ci_navigation_duration_seconds{${labelsBase},quantile="${quantileLabel}"} ${navigationSeconds}`);
}
if (journey.aggregated?.largest_contentful_paint?.value_ms !== undefined) {
const lcpSeconds = journey.aggregated.largest_contentful_paint.value_ms / 1000;
const quantileLabel = journey.aggregated.largest_contentful_paint.quantile;
lines.push(`ci_lcp_seconds{${labelsBase},quantile="${quantileLabel}"} ${lcpSeconds}`);
}
const metaLabels = [`journey="${journey.id}"`];
if (GIT_SHA) {
metaLabels.push(`git_sha="${GIT_SHA}"`);
}
if (GIT_REF) {
metaLabels.push(`git_ref="${GIT_REF}"`);
}
lines.push(`# TYPE ci_perf_build_info gauge`);
lines.push(`ci_perf_build_info{${metaLabels.join(',')}} 1`);
return lines.join('\n');
};
const formatPrometheusBody = (journey) => {
const labelsBase = `journey="${journey.id}"`;
const lines = [
'# TYPE ci_navigation_duration_seconds summary',
];
if (journey.aggregated?.navigation_duration?.value_ms !== undefined) {
const navigationSeconds = journey.aggregated.navigation_duration.value_ms / 1000;
const quantileLabel = journey.aggregated.navigation_duration.quantile;
lines.push(`ci_navigation_duration_seconds{${labelsBase},quantile="${quantileLabel}"} ${navigationSeconds}`);
}
if (journey.aggregated?.largest_contentful_paint?.value_ms !== undefined) {
const lcpSeconds = journey.aggregated.largest_contentful_paint.value_ms / 1000;
const quantileLabel = journey.aggregated.largest_contentful_paint.quantile;
lines.push(`ci_lcp_seconds{${labelsBase},quantile="${quantileLabel}"} ${lcpSeconds}`);
}
const metaLabels = [`journey="${journey.id}"`];
if (GIT_SHA) {
metaLabels.push(`git_sha="${GIT_SHA}"`);
}
if (GIT_REF) {
metaLabels.push(`git_ref="${GIT_REF}"`);
}
lines.push(`# TYPE ci_perf_build_info gauge`);
lines.push(`ci_perf_build_info{${metaLabels.join(',')}} 1`);
return lines.join('\n') + '\n';
};
🤖 Prompt for AI Agents
In scripts/publish-perf-metrics.mjs around lines 33 to 63, the Prometheus
exposition payload is built with lines.join('\n') but lacks a trailing newline;
update the return to append a final '\n' (e.g., return lines.join('\n') + '\n')
so the output conforms to Prometheus exposition format and is compatible with
all parsers.

Comment on lines +82 to +133
const toJUnit = (journeyMetrics) => {
const testcases = journeyMetrics.map((journey) => {
const name = journey.description ? `${journey.id} — ${journey.description}` : journey.id;
const lines = [];
if (journey.aggregated?.navigation_duration) {
const metric = journey.aggregated.navigation_duration;
lines.push(`navigation_${metric.quantile}=${metric.value_ms.toFixed(2)}ms`);
if (metric.threshold_ms !== undefined) {
lines.push(`navigation_threshold=${metric.threshold_ms}`);
}
lines.push(`navigation_passed=${metric.passed}`);
}
if (journey.aggregated?.largest_contentful_paint) {
const metric = journey.aggregated.largest_contentful_paint;
lines.push(`lcp_${metric.quantile}=${metric.value_ms.toFixed(2)}ms`);
if (metric.threshold_ms !== undefined) {
lines.push(`lcp_threshold=${metric.threshold_ms}`);
}
lines.push(`lcp_passed=${metric.passed}`);
}
const systemOut = lines.join('\n');
const failure = Object.values(journey.aggregated ?? {}).some((metric) => metric && metric.threshold_ms !== undefined && !metric.passed);
return {
name,
systemOut,
failure,
failureMessage: failure ? 'Performance budget regression detected' : undefined,
};
});

const failures = testcases.filter((tc) => tc.failure).length;
const xmlParts = [
'<?xml version="1.0" encoding="UTF-8"?>',
`<testsuites tests="${testcases.length}" failures="${failures}">`,
`<testsuite name="performance-budget" tests="${testcases.length}" failures="${failures}">`,
];

for (const testcase of testcases) {
xmlParts.push(`<testcase classname="performance-budget" name="${testcase.name}">`);
if (testcase.failure && testcase.failureMessage) {
xmlParts.push(`<failure message="${testcase.failureMessage}"/>`);
}
if (testcase.systemOut) {
xmlParts.push(`<system-out><![CDATA[${testcase.systemOut}]]></system-out>`);
}
xmlParts.push('</testcase>');
}

xmlParts.push('</testsuite>');
xmlParts.push('</testsuites>');
return xmlParts.join('');
};
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Add XML escaping for dynamic content in JUnit output.

The JUnit XML generation doesn't escape special XML characters in testcase.name (line 120) and testcase.failureMessage (line 122). If journey IDs or descriptions contain characters like <, >, &, or ", the generated XML will be malformed.

Add an XML escape helper:

+const escapeXml = (str) => 
+  str.replace(/[<>&"]/g, (char) => {
+    const entities = { '<': '&lt;', '>': '&gt;', '&': '&amp;', '"': '&quot;' };
+    return entities[char];
+  });
+
 const toJUnit = (journeyMetrics) => {
   const testcases = journeyMetrics.map((journey) => {
-    const name = journey.description ? `${journey.id} — ${journey.description}` : journey.id;
+    const name = escapeXml(journey.description ? `${journey.id} — ${journey.description}` : journey.id);

Then use it when outputting the attributes:

   for (const testcase of testcases) {
-    xmlParts.push(`<testcase classname="performance-budget" name="${testcase.name}">`);
+    xmlParts.push(`<testcase classname="performance-budget" name="${testcase.name}">`);
     if (testcase.failure && testcase.failureMessage) {
-      xmlParts.push(`<failure message="${testcase.failureMessage}"/>`);
+      xmlParts.push(`<failure message="${escapeXml(testcase.failureMessage)}"/>`);
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const toJUnit = (journeyMetrics) => {
const testcases = journeyMetrics.map((journey) => {
const name = journey.description ? `${journey.id}${journey.description}` : journey.id;
const lines = [];
if (journey.aggregated?.navigation_duration) {
const metric = journey.aggregated.navigation_duration;
lines.push(`navigation_${metric.quantile}=${metric.value_ms.toFixed(2)}ms`);
if (metric.threshold_ms !== undefined) {
lines.push(`navigation_threshold=${metric.threshold_ms}`);
}
lines.push(`navigation_passed=${metric.passed}`);
}
if (journey.aggregated?.largest_contentful_paint) {
const metric = journey.aggregated.largest_contentful_paint;
lines.push(`lcp_${metric.quantile}=${metric.value_ms.toFixed(2)}ms`);
if (metric.threshold_ms !== undefined) {
lines.push(`lcp_threshold=${metric.threshold_ms}`);
}
lines.push(`lcp_passed=${metric.passed}`);
}
const systemOut = lines.join('\n');
const failure = Object.values(journey.aggregated ?? {}).some((metric) => metric && metric.threshold_ms !== undefined && !metric.passed);
return {
name,
systemOut,
failure,
failureMessage: failure ? 'Performance budget regression detected' : undefined,
};
});
const failures = testcases.filter((tc) => tc.failure).length;
const xmlParts = [
'<?xml version="1.0" encoding="UTF-8"?>',
`<testsuites tests="${testcases.length}" failures="${failures}">`,
`<testsuite name="performance-budget" tests="${testcases.length}" failures="${failures}">`,
];
for (const testcase of testcases) {
xmlParts.push(`<testcase classname="performance-budget" name="${testcase.name}">`);
if (testcase.failure && testcase.failureMessage) {
xmlParts.push(`<failure message="${testcase.failureMessage}"/>`);
}
if (testcase.systemOut) {
xmlParts.push(`<system-out><![CDATA[${testcase.systemOut}]]></system-out>`);
}
xmlParts.push('</testcase>');
}
xmlParts.push('</testsuite>');
xmlParts.push('</testsuites>');
return xmlParts.join('');
};
// add this helper above `toJUnit`
const escapeXml = (str) =>
str.replace(/[<>&"]/g, (char) => {
const entities = { '<': '&lt;', '>': '&gt;', '&': '&amp;', '"': '&quot;' };
return entities[char];
});
const toJUnit = (journeyMetrics) => {
const testcases = journeyMetrics.map((journey) => {
const name = escapeXml(
journey.description
? `${journey.id}${journey.description}`
: journey.id
);
const lines = [];
if (journey.aggregated?.navigation_duration) {
const metric = journey.aggregated.navigation_duration;
lines.push(`navigation_${metric.quantile}=${metric.value_ms.toFixed(2)}ms`);
if (metric.threshold_ms !== undefined) {
lines.push(`navigation_threshold=${metric.threshold_ms}`);
}
lines.push(`navigation_passed=${metric.passed}`);
}
if (journey.aggregated?.largest_contentful_paint) {
const metric = journey.aggregated.largest_contentful_paint;
lines.push(`lcp_${metric.quantile}=${metric.value_ms.toFixed(2)}ms`);
if (metric.threshold_ms !== undefined) {
lines.push(`lcp_threshold=${metric.threshold_ms}`);
}
lines.push(`lcp_passed=${metric.passed}`);
}
const systemOut = lines.join('\n');
const failure = Object.values(journey.aggregated ?? {})
.some((metric) => metric && metric.threshold_ms !== undefined && !metric.passed);
return {
name,
systemOut,
failure,
failureMessage: failure
? 'Performance budget regression detected'
: undefined,
};
});
const failures = testcases.filter((tc) => tc.failure).length;
const xmlParts = [
'<?xml version="1.0" encoding="UTF-8"?>',
`<testsuites tests="${testcases.length}" failures="${failures}">`,
`<testsuite name="performance-budget" tests="${testcases.length}" failures="${failures}">`,
];
for (const testcase of testcases) {
xmlParts.push(
`<testcase classname="performance-budget" name="${testcase.name}">`
);
if (testcase.failure && testcase.failureMessage) {
xmlParts.push(
`<failure message="${escapeXml(testcase.failureMessage)}"/>`
);
}
if (testcase.systemOut) {
xmlParts.push(
`<system-out><![CDATA[${testcase.systemOut}]]></system-out>`
);
}
xmlParts.push('</testcase>');
}
xmlParts.push('</testsuite>');
xmlParts.push('</testsuites>');
return xmlParts.join('');
};
🤖 Prompt for AI Agents
In scripts/publish-perf-metrics.mjs around lines 82 to 133, the JUnit XML writer
inserts testcase.name and failureMessage directly into XML attributes which can
break the output if they contain characters like <, >, &, " or '. Add a small
xmlEscape helper that replaces & with &amp;, < with &lt;, > with &gt;, " with
&quot; and ' with &apos;, and call it when interpolating testcase.name and
testcase.failureMessage into the attribute values (leave the CDATA-wrapped
systemOut unchanged).

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Oct 17, 2025

Note

Docstrings generation - SUCCESS
Generated docstrings for this pull request at #158

coderabbitai bot added a commit that referenced this pull request Oct 17, 2025
Docstrings generation was requested by @shayancoin.

* #99 (comment)

The following files were modified:

* `scripts/ci/check_canary_budgets.py`
@shayancoin shayancoin merged commit 84b30b0 into main Oct 17, 2025
0 of 6 checks passed
shayancoin pushed a commit that referenced this pull request Oct 18, 2025
…158)

Docstrings generation was requested by @shayancoin.

* #99 (comment)

The following files were modified:

* `scripts/ci/check_canary_budgets.py`

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant