Skip to content

fix(TimePicker): stabilize flaky time-picker e2e tests#2516

Open
wrn14897 wants to merge 4 commits into
mainfrom
warren/fix-flaky-timepicker-e2e
Open

fix(TimePicker): stabilize flaky time-picker e2e tests#2516
wrn14897 wants to merge 4 commits into
mainfrom
warren/fix-flaky-timepicker-e2e

Conversation

@wrn14897

Copy link
Copy Markdown
Member

Why

The custom-time-range and relative-time search e2e specs are flaky on CI — custom-time-range even hard-fails on all retries on some runs (e.g. this Shard 3 run). The flakiness is unrelated to the PR that surfaced it; it's a latent bug in the TimePicker plus non-deterministic Playwright helpers.

Root causes

  1. Stale derived state (app bug). isRelative was seeded from defaultRelativeTimeMode only at mount, so the relative/absolute toggle silently diverged from URL state when the live interval changed — the picker rendered in a mode that no longer matched the URL.
  2. Non-idempotent open() helper. Clicking the input toggles the popover. Right after a programmatic close (apply/select), a click could be swallowed by a re-render (popover stays shut) or land while the popover was still detaching (flicker open→closed), so the date inputs / interval buttons / relative switch were "not found".
  3. Cross-test state leak. The picker mode is persisted in localStorage via atomWithStorage('hdx-time-picker-mode'), so a prior test that switched to "Around a time" leaked into the reopen test (only one date input rendered → endDateInput never resolved). This is what made custom-time-range hard-fail.
  4. Helpers that hung to the 60s timeout instead of failing fast (reading isChecked() / clicking a re-rendering button).

What changed

AppTimePicker.tsx

  • Sync isRelative to defaultRelativeTimeMode when the prop becomes true. Force on only: the prop is a lossy signal (also false for relative mode at the default live-tail interval), so syncing back to false would wrongly drop the user out of relative mode when they pick the default interval.

TestsTimePickerComponent.ts

  • open() now ends in a stably-open state via a settle+retry loop (handles already-open, closed, mid-close, and swallowed-click cases).
  • apply() waits for the popover to close + network to settle so a following open() starts settled.
  • toggleRelativeTimeSwitch / selectTimeInterval / isRelativeTimeEnabled retry or fail fast instead of hanging.

Tests — specs

  • custom-time-range: reset the persisted hdx-time-picker-mode atom in beforeEach for isolation.
  • relative-time: open the popover at the start of each loop iteration instead of relying on trailing state.

Verification (local, full-stack e2e)

  • custom-time-range: 4 consecutive clean runs
  • relative-time: 4 consecutive clean runs
  • Both together at --workers=2 (CI parity): 4 consecutive clean runs (16/16)
  • Entire search/ e2e directory at --workers=2: 65/65 passing (covers every spec using the shared TimePickerComponent)
  • tsc --noEmit clean; eslint clean

A changeset is included (@hyperdx/app patch).

The custom-time-range and relative-time e2e specs were flaky (and the
reopen test hard-failed) due to a stale-state bug in the TimePicker and
non-deterministic Playwright helpers.

App fix:
- isRelative was only seeded from defaultRelativeTimeMode at mount, so the
  relative/absolute toggle silently diverged from URL state when intervals
  changed. Sync it when the prop becomes true (force on only -- the prop is
  a lossy signal that is also false for relative mode at the default
  live-tail interval).

Test fixes:
- open(): end in a stably-open state with a settle+retry loop instead of
  returning a popover that is mid-close.
- apply(): wait for the popover to close and the network to settle so a
  following open() starts from a settled state.
- toggleRelativeTimeSwitch / selectTimeInterval / isRelativeTimeEnabled:
  retry or fail fast instead of hanging to the 60s test timeout.
- custom-time-range: reset the persisted hdx-time-picker-mode atom in
  beforeEach so a prior test's mode cannot leak across the shared worker.
- relative-time: open the popover at the start of each loop iteration
  rather than relying on trailing state.
@changeset-bot

changeset-bot Bot commented Jun 24, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: f81af35

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 3 packages
Name Type
@hyperdx/app Patch
@hyperdx/api Patch
@hyperdx/otel-collector Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@vercel

vercel Bot commented Jun 24, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

2 Skipped Deployments
Project Deployment Actions Updated (UTC)
hyperdx-oss Ignored Ignored Preview Jul 7, 2026 6:02pm
hyperdx-storybook Ignored Ignored Preview Jul 7, 2026 6:02pm

Request Review

@github-actions github-actions Bot added the review/tier-2 Low risk — AI review + quick human skim label Jun 24, 2026
@github-actions

github-actions Bot commented Jun 24, 2026

Copy link
Copy Markdown
Contributor

🔵 Tier 2 — Low Risk

Small, isolated change with no API route or data model modifications.

Why this tier:

  • Standard feature/fix — introduces new logic or modifies core functionality

Review process: AI review + quick human skim (target: 5–15 min). Reviewer validates AI assessment and checks for domain-specific concerns.
SLA: Resolve within 4 business hours.

Stats
  • Production files changed: 1
  • Production lines changed: 17 (+ 162 in test files, excluded from tier calculation)
  • Branch: warren/fix-flaky-timepicker-e2e
  • Author: wrn14897

To override this classification, remove the review/tier-2 label and apply a different review/tier-* label. Manual overrides are preserved on subsequent pushes.

@greptile-apps

greptile-apps Bot commented Jun 24, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes three root causes of flaky custom-time-range and relative-time e2e tests: stale isRelative state in TimePicker.tsx (fixed with a one-directional useEffect), non-idempotent open() / retry helpers in TimePickerComponent.ts, and cross-test localStorage leakage from Jotai's atomWithStorage.

  • App fix (TimePicker.tsx): a useEffect now promotes isRelative to true when defaultRelativeTimeMode becomes true, fixing the URL-vs-toggle divergence. The sync is intentionally one-directional because defaultRelativeTimeMode=false is ambiguous between absolute mode and the default live-tail interval.
  • Helper hardening (TimePickerComponent.ts): open() gains a settle+retry loop to handle mid-close and swallowed-click races; apply() now waits for the popover to fully close; toggleRelativeTimeSwitch and selectTimeInterval retry on re-render noise instead of hanging.
  • Spec isolation (custom-time-range.spec.ts, relative-time.spec.ts): addInitScript resets the persisted picker-mode atom before each test, and the relative-time loop opens the popover at the top of each iteration rather than relying on trailing state.

Confidence Score: 5/5

Safe to merge — the app change is a minimal, well-bounded useEffect touching only internal toggle state, and the test changes are additive retry/settle logic with no changes to production paths.

The React fix is narrow (one useEffect, one state variable, no new props or external dependencies) and the documented asymmetric sync rule prevents the known false-negative case. Test helper changes are purely additive and verified across 16 consecutive local runs including parallel workers. No production logic outside the TimePicker component is touched.

No files require special attention; the one minor inconsistency (unguarded networkidle in open()) is a defensive hardening opportunity rather than a current breakage.

Important Files Changed

Filename Overview
packages/app/src/components/TimePicker/TimePicker.tsx Adds a one-directional useEffect that promotes isRelative to true when defaultRelativeTimeMode becomes true, fixing the stale-state root cause. The asymmetric sync (never forces OFF) is intentional and correctly documented.
packages/app/tests/e2e/components/TimePickerComponent.ts Rewrites open() with a settle+retry loop, adds isPopoverStablyOpen() helper, and tightens toggleRelativeTimeSwitch/selectTimeInterval/apply with retries and settled-state waits. The open() method has a minor inconsistency: waitForLoadState('networkidle') is unguarded, unlike apply() which catches the same call in case live-tail keeps the network busy.
packages/app/tests/e2e/features/search/custom-time-range.spec.ts Adds addInitScript to clear the hdx-time-picker-mode localStorage key before each test, eliminating cross-test state leak from atomWithStorage. Correct approach for pre-page-script isolation.
packages/app/tests/e2e/features/search/relative-time.spec.ts Moves timePicker.open() to the top of the interval loop so each iteration starts from a known state rather than relying on the previous iteration leaving the popover open. Clean fix for a sequencing bug.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant Spec as Test Spec
    participant TPC as TimePickerComponent
    participant Page as Playwright Page
    participant App as TimePicker (React)

    Note over Spec,App: open() — settle+retry loop
    Spec->>TPC: open()
    TPC->>Page: waitForLoadState('networkidle')
    loop up to 5 attempts
        TTC->>TPC: isPopoverStablyOpen()?
        alt stably open
            TPC-->>Spec: return (popover ready)
        else not stable
            TPC->>App: waitFor(hidden, 2s)
            TPC->>Page: keyboard.press('Escape')
            TPC->>App: pickerInput.click()
            TPC->>TPC: isPopoverStablyOpen()?
        end
    end

    Note over Spec,App: apply() — wait for settled close
    Spec->>TPC: apply()
    TPC->>App: pickerApplyButton.click()
    App->>App: handleApply → close()
    TPC->>App: waitFor(hidden, 5s)
    TPC->>Page: waitForLoadState('networkidle')
    TPC-->>Spec: return

    Note over Spec,App: TimePicker.tsx — isRelative sync fix
    App->>App: defaultRelativeTimeMode prop changes (→ true)
    App->>App: useEffect fires → setIsRelative(true)
    Note right of App: Never forced OFF (prop is lossy)
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant Spec as Test Spec
    participant TPC as TimePickerComponent
    participant Page as Playwright Page
    participant App as TimePicker (React)

    Note over Spec,App: open() — settle+retry loop
    Spec->>TPC: open()
    TPC->>Page: waitForLoadState('networkidle')
    loop up to 5 attempts
        TTC->>TPC: isPopoverStablyOpen()?
        alt stably open
            TPC-->>Spec: return (popover ready)
        else not stable
            TPC->>App: waitFor(hidden, 2s)
            TPC->>Page: keyboard.press('Escape')
            TPC->>App: pickerInput.click()
            TPC->>TPC: isPopoverStablyOpen()?
        end
    end

    Note over Spec,App: apply() — wait for settled close
    Spec->>TPC: apply()
    TPC->>App: pickerApplyButton.click()
    App->>App: handleApply → close()
    TPC->>App: waitFor(hidden, 5s)
    TPC->>Page: waitForLoadState('networkidle')
    TPC-->>Spec: return

    Note over Spec,App: TimePicker.tsx — isRelative sync fix
    App->>App: defaultRelativeTimeMode prop changes (→ true)
    App->>App: useEffect fires → setIsRelative(true)
    Note right of App: Never forced OFF (prop is lossy)
Loading

Reviews (4): Last reviewed commit: "Merge branch 'main' into warren/fix-flak..." | Re-trigger Greptile

Comment on lines +106 to +115
private async isPopoverStablyOpen(): Promise<boolean> {
if (!(await this.pickerPopover.isVisible())) return false;
// Dwell long enough to outlast the post-apply / post-select re-render
// cascade (URL update + results reload) that can close a just-opened
// popover. Sample twice so a mid-cascade flicker is caught.
await this.page.waitForTimeout(250);
if (!(await this.pickerPopover.isVisible())) return false;
await this.page.waitForTimeout(250);
return await this.pickerPopover.isVisible();
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Fixed-delay polling in isPopoverStablyOpen

waitForTimeout is explicitly discouraged in Playwright because it adds a hard delay regardless of actual app state — here 500 ms of unconditional waiting per call, multiplied by every open() invocation on an already-open popover. The goal (detecting that the popover stays visible through a re-render cascade) could instead be expressed as a short expect(locator).toBeVisible() assertion with a tight timeout polled at Playwright's own rate, which would resolve immediately once stability is confirmed rather than always burning the full 500 ms. That said, the two-sample approach is documented and the behaviour is correct; this is a test-speed and style concern rather than a correctness issue.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Claude Code Fix in Conductor Fix in Cursor Fix in Codex

Comment on lines +201 to +214
let lastError: unknown;
for (let attempt = 0; attempt < 4; attempt++) {
try {
await intervalButton.waitFor({ state: 'visible', timeout: 5000 });
await intervalButton.click({ timeout: 5000 });
return;
} catch (error) {
lastError = error;
// The popover may have closed (e.g. a swallowed render); make sure it
// is open again before the next attempt.
await this.open();
}
}
throw lastError;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Redundant open() call on the final catch iteration

On the fourth (last) loop pass, when the click still fails, the catch block calls this.open() and then the loop exits — no further attempt will use the freshly opened popover. The call is harmless but leaves the popover in an unexpected open state right before throw lastError surfaces the error to the caller, which could make post-failure diagnostics (e.g. screenshots) show a spurious open picker. Consider guarding with if (attempt < 3) or hoisting the open() to the top of each iteration (similar to the pattern used in relative-time.spec.ts) so every retry starts from a known state without the trailing wasted open.

Fix in Claude Code Fix in Conductor Fix in Cursor Fix in Codex

@github-actions

github-actions Bot commented Jun 24, 2026

Copy link
Copy Markdown
Contributor

E2E Test Results

All tests passed • 226 passed • 3 skipped • 1530s

Status Count
✅ Passed 226
❌ Failed 0
⚠️ Flaky 1
⏭️ Skipped 3

Tests ran across 4 shards in parallel.

View full report →

traces-workflow types a query into the search input (rendering the
autocomplete 'Searching for:' portal) and then opens the time picker. That
portal overlays the time-picker input and intercepted the toggle click,
hard-failing the test on all retries.

open() now presses Escape from its closed baseline before clicking the
input, dismissing any blocking overlay. Escape is only pressed when the
popover is already closed, so it cannot race a just-opened popover shut.
@github-actions

github-actions Bot commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Deep Review

✅ No critical issues found. No P0/P1 ship-blockers: the production change is a narrowly-scoped, well-commented one-way state sync, and the rest is Playwright test-helper hardening. The items below are recommendations and nits.

🟡 P2 -- recommended

  • packages/app/src/components/TimePicker/TimePicker.tsx:249 -- the new force-ON useEffect is the actual app fix, but no test exercises it: the only covering spec (relative-time.spec.ts "restore relative toggle from URL") uses page.reload(), which remounts the component so the pre-existing useState(defaultRelativeTimeMode) seeding passes the assertion even if the effect were deleted.
    • Fix: Add a test that drives a same-mount defaultRelativeTimeMode false→true transition (e.g. history navigation via page.goBack()/goForward() without a full remount) and asserts the relative switch becomes checked.
    • testing, correctness, maintainability, project-standards, kieran-typescript
  • packages/app/tests/e2e/components/TimePickerComponent.ts:145 -- toggleRelativeTimeSwitch captures before once outside the loop, so if a click flips the switch but the popover closes during the 2s toBeChecked wait, the catch reopens and the next click flips it back, letting the helper oscillate and settle on the inverted state -- the same class of flakiness this PR targets.
    • Fix: Re-read isChecked() at the top of each attempt and click only when the current state does not yet equal the target, making each attempt idempotent.
    • adversarial, correctness, julik-frontend-races
🔵 P3 nitpicks (5)
  • packages/app/tests/e2e/components/TimePickerComponent.ts:220 -- selectTimeInterval (and toggleRelativeTimeSwitch) call the 5-attempt open() inside their own 4-attempt loop, so a genuinely broken interval retries into a ~90s wall-clock that exceeds the 60s test timeout and loses the intended lastError diagnostic, defeating the fail-fast goal.
    • Fix: Bound total retry time or re-open at most once before failing, rather than nesting one multi-attempt loop inside another.
  • packages/app/tests/e2e/components/TimePickerComponent.ts:249 -- the force-ON useEffect never syncs back to false, so a later defaultRelativeTimeMode false→true transition (e.g. Resume Live Tail at a non-default interval via handleResumeLiveTail) overrides a user's explicit relative-off choice with no record of that intent.
    • Fix: Track an explicit user-override flag and skip the force-on while it is set, clearing it only when the popover reopens or the prop genuinely goes false.
  • packages/app/tests/e2e/components/TimePickerComponent.ts:270 -- apply() swallows the popover-hidden waitFor rejection with an empty .catch(() => {}) and has no follow-up assertion, so a popover that never closes surfaces later at an unrelated assertion with a misleading message.
    • Fix: Re-throw a wrapped, specific error from the apply() catch so a stuck-popover regression fails at the correct call site.
  • packages/app/tests/e2e/components/TimePickerComponent.ts:147 -- the "act, assert expected state, catch, reopen, retry" loop is duplicated between toggleRelativeTimeSwitch and selectTimeInterval.
    • Fix: Extract a shared retryUntilStable(action, check, attempts) helper so the retry policy lives in one place.
  • packages/app/tests/e2e/components/TimePickerComponent.ts:210 -- let lastError: unknown is declared without an initializer and thrown after the loop; it is runtime-safe and does not break tsc (unknown includes undefined), but reads less clearly than the initialized let lastError: Error | null = null pattern used elsewhere in the e2e suite.
    • Fix: Initialize lastError and hoist the 5/4 retry counts and 250 ms dwell into named constants for clarity.

Reviewers (9): correctness, testing, maintainability, project-standards, kieran-typescript, julik-frontend-races, adversarial, agent-native, learnings-researcher.

Testing gaps:

  • No test asserts the user can toggle relative mode off and have it persist across a defaultRelativeTimeMode re-transition (the exact asymmetry the app change introduces).
  • custom-time-range.spec.ts resets the persisted hdx-time-picker-mode atom in beforeEach; relative-time.spec.ts does not -- currently harmless since those specs do not depend on Range/Around mode, but the isolation is asymmetric.
  • No coverage of the retry helpers' exhaustion path, so a real app regression that reintroduces the click race could be masked as intermittent slowness until the final throw fires.

@pulpdrew pulpdrew left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LGTM

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

review/tier-2 Low risk — AI review + quick human skim

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants