diff --git a/docs/KNOWN_ISSUES.md b/docs/KNOWN_ISSUES.md
index f8161dc461..79eec55f07 100644
--- a/docs/KNOWN_ISSUES.md
+++ b/docs/KNOWN_ISSUES.md
@@ -95,3 +95,28 @@
HTTPS + system trust store + code-signature verification of the downloaded artifact. The blast
radius of a stale or broken pin exceeds the risk it would remove.
- Affected files: electron-builder.json (update feed config); no source changes made.
+
+## `prefers-reduced-motion` diverges across CI runners — `getComputedStyle` transition assertions are platform-flaky
+
+- Status: Confirmed (GitHub-hosted `windows-latest`/`macos-latest` runners, PR #3443 and #3449
+ CI runs; local dev Macs and the `ubuntu-latest`/xvfb runner unaffected).
+- Symptom: A renderer spec that asserts on a CSS transition/animation property via
+ `getComputedStyle(element).transitionDuration` (or similar) passes locally and on Linux CI but
+ fails or reports a different value on GitHub-hosted Windows and macOS runners.
+- Root cause: `getComputedStyle` resolves the live cascade, which means it evaluates the
+ `prefers-reduced-motion` media query against the runner's actual OS-level accessibility
+ setting. GitHub-hosted Windows and macOS runner images report `prefers-reduced-motion: reduce`
+ at the OS level, while the `ubuntu-latest` + xvfb runner and local developer Macs report
+ `no-preference`. A component that opts out of animation under reduced motion therefore renders
+ with different computed transition values depending solely on which CI platform is running the
+ test — the assertion is checking the runner's OS setting, not the component's logic.
+- Workaround: Do not assert transition/animation values via `getComputedStyle` in specs. Instead,
+ read the parsed CSSOM directly — walk `document.styleSheets`, find the plain style rule and the
+ `@media (prefers-reduced-motion: reduce)` override rule that target the element's own
+ emotion-generated class. Both rules exist in the CSSOM regardless of which value the media
+ query currently resolves to on that runner, so the assertion is deterministic on every
+ platform. See `findTransitionRulesForElement` in
+ `src/ui/components/TopBar/DownloadsIndicator.spec.tsx` (~lines 80-138) for the pattern.
+- Affected files: src/ui/components/TopBar/DownloadsIndicator.spec.tsx.
+- Reference: PR #3443 (introduced the animated percentage slot), PR #3449 (documented after CI
+ runs surfaced the divergence).
diff --git a/src/ui/components/Shell/index.spec.tsx b/src/ui/components/Shell/index.spec.tsx
index 731b0ea4ed..8863635b08 100644
--- a/src/ui/components/Shell/index.spec.tsx
+++ b/src/ui/components/Shell/index.spec.tsx
@@ -46,13 +46,16 @@ jest.mock('../TopBar', () => ({
__esModule: true,
TopBar: ({
leadingSlot,
+ centerSlot,
trailingSlot,
}: {
leadingSlot?: React.ReactNode;
+ centerSlot?: React.ReactNode;
trailingSlot?: React.ReactNode;
}) => (
{leadingSlot}
+ {centerSlot}
{trailingSlot}
),
@@ -410,6 +413,47 @@ describe('Shell', () => {
});
});
+ describe('linux chrome', () => {
+ let restorePlatform: () => void;
+
+ afterEach(() => {
+ restorePlatform?.();
+ });
+
+ it('mounts the TopBar with the downloads indicator, and no window controls, when navigationLayout is sidebar', () => {
+ restorePlatform = setPlatform('linux');
+
+ renderWithStore(, {
+ preloadedState: buildState({ navigationLayout: 'sidebar' }),
+ });
+
+ expect(screen.getByTestId('top-bar')).toBeInTheDocument();
+ expect(screen.getByTestId('downloads-indicator')).toBeInTheDocument();
+ expect(screen.queryByTestId('window-controls')).not.toBeInTheDocument();
+ expect(screen.getByTestId('tab-bar')).toHaveAttribute(
+ 'data-orientation',
+ 'vertical'
+ );
+ expect(screen.getByTestId('meatball-menu-button')).toBeInTheDocument();
+ });
+
+ it('mounts the TopBar with the downloads indicator and server switcher, and no window controls or TabBar, when navigationLayout is hidden', () => {
+ restorePlatform = setPlatform('linux');
+
+ renderWithStore(, {
+ preloadedState: buildState({ navigationLayout: 'hidden' }),
+ });
+
+ expect(screen.getByTestId('top-bar')).toBeInTheDocument();
+ expect(screen.getByTestId('downloads-indicator')).toBeInTheDocument();
+ expect(screen.queryByTestId('window-controls')).not.toBeInTheDocument();
+ expect(screen.queryByTestId('tab-bar')).not.toBeInTheDocument();
+ expect(
+ screen.getByRole('button', { name: 'tabBar.workspaces' })
+ ).toBeInTheDocument();
+ });
+ });
+
describe('downloads indicator: exactly one instance, compact in the thin TopBar', () => {
it('renders exactly one instance on the darwin TopBar (sidebar layout), compact', () => {
const restorePlatform = setPlatform('darwin');
diff --git a/src/ui/components/Shell/index.tsx b/src/ui/components/Shell/index.tsx
index 2795cc0e5d..a3045430b7 100644
--- a/src/ui/components/Shell/index.tsx
+++ b/src/ui/components/Shell/index.tsx
@@ -95,19 +95,20 @@ export const Shell = () => {
}
/>
)}
- {navigationLayout !== 'tabs' && process.platform === 'darwin' && (
- : undefined
- }
- trailingSlot={
- <>
-
-
- >
- }
- />
- )}
+ {navigationLayout !== 'tabs' &&
+ ['darwin', 'linux'].includes(process.platform) && (
+ : undefined
+ }
+ trailingSlot={
+ <>
+
+
+ >
+ }
+ />
+ )}
{navigationLayout !== 'tabs' && process.platform === 'win32' && (
{
}),
});
- expect(screen.getByTestId('downloads-unseen-dot')).toBeInTheDocument();
+ expect(
+ await screen.findByTestId('downloads-unseen-dot')
+ ).toBeInTheDocument();
await user.click(
screen.getByRole('button', { name: 'tabBar.downloads.title' })
@@ -710,6 +712,8 @@ describe('DownloadsIndicator', () => {
}),
});
+ screen.getByTestId('downloads-unseen-dot');
+
const glyph = screen.getByTestId('downloads-glyph');
const paths = glyph.querySelectorAll('path');
@@ -731,34 +735,44 @@ describe('DownloadsIndicator', () => {
// seenAt is initialized to the mount-time Date.now(); endTime must sit
// between that mount time and the later click's own Date.now() call
// for the download to start unseen and end up seen after the click.
- const mountTime = Date.now();
- renderWithStore(, {
- preloadedState: buildState({
- 1: {
- ...baseDownload,
- state: 'completed',
- receivedBytes: 1000,
- startTime: SESSION_START + 1000,
- endTime: mountTime + 1,
- },
- }),
- });
+ // Date.now is pinned for the duration of this test to make that
+ // ordering deterministic instead of racing the wall clock.
+ const NOW = Date.now();
+ const spy = jest.spyOn(Date, 'now').mockReturnValue(NOW);
+
+ try {
+ renderWithStore(, {
+ preloadedState: buildState({
+ 1: {
+ ...baseDownload,
+ state: 'completed',
+ receivedBytes: 1000,
+ startTime: SESSION_START + 1000,
+ endTime: NOW + 1,
+ },
+ }),
+ });
- expect(screen.getByTestId('downloads-unseen-dot')).toBeInTheDocument();
+ expect(screen.getByTestId('downloads-unseen-dot')).toBeInTheDocument();
- await user.click(
- screen.getByRole('button', { name: 'tabBar.downloads.title' })
- );
- await user.click(screen.getByTestId('downloads-panel-backdrop'));
+ spy.mockReturnValue(NOW + 60_000);
- const glyph = screen.getByTestId('downloads-glyph');
- const paths = glyph.querySelectorAll('path');
- expect(paths).toHaveLength(1);
- expect(paths[0].getAttribute('d')).toContain(TRACK_CIRCLE_D);
- expect(paths[0].getAttribute('d')).toContain(OUTER_CIRCLE_D);
- expect(paths[0].getAttribute('d')).toContain(ARROW_PATH);
+ await user.click(
+ screen.getByRole('button', { name: 'tabBar.downloads.title' })
+ );
+ await user.click(screen.getByTestId('downloads-panel-backdrop'));
- expect(glyph.querySelectorAll('circle')).toHaveLength(0);
+ const glyph = screen.getByTestId('downloads-glyph');
+ const paths = glyph.querySelectorAll('path');
+ expect(paths).toHaveLength(1);
+ expect(paths[0].getAttribute('d')).toContain(TRACK_CIRCLE_D);
+ expect(paths[0].getAttribute('d')).toContain(OUTER_CIRCLE_D);
+ expect(paths[0].getAttribute('d')).toContain(ARROW_PATH);
+
+ expect(glyph.querySelectorAll('circle')).toHaveLength(0);
+ } finally {
+ spy.mockRestore();
+ }
});
it('does not spin the unseen-completed arc (indeterminate spin only applies while actually downloading)', () => {