Skip to content

Commit 507afb0

Browse files
committed
E2E speedup (Step 2): replace fixed sleeps with condition polling
Cuts Playwright wall-clock from 10m12s to 5m6s (-50%) and checker total from 13m12s to 6m25s (-51%) by replacing the highest-payoff fixed sleeps with `pollUntil` / `waitForSelector` conditions. Highlights: - `pollUntil` default interval 100 → 50 ms - `ensureAppReady`: dropped `sleep(500)` after route nav (waitForSelector follows) and `sleep(300)` after mcp-nav-to-path (file-poll covers it) - MTP / SMB / network-toggle `beforeEach`: `sleep(2000)` after volume switch → poll `cmdr://state` for both panes on local volume - a11y `setTheme`: dropped `sleep(2000)` entirely — only mattered for color-contrast which is disabled in this suite - a11y Settings-section loop: trimmed two `sleep(500)`s (one redundant with pollUntil-visibility, the other shortened) - MTP 50 MB copy: `sleep(10000)` → poll `fs.statSync(dest).size` - mtp-conflicts post-op `sleep(3000)` × 5: poll the actual file-state contract (src absent + dest content) or drop where `waitForDialogsToClose` already gates the user-visible signal - error-pane: `sleep(2000)` after navigate → poll `.error-pane` visibility - file-watching deleted-dir test: `sleep(3000)` → 5 s poll for temp file to drop from listing Also fixes a pre-existing lint warning in the sleep-instrumentation (`${ms}` template expression). Suite result: 122 expected pass, 17 skipped, 0 failed, 0 flaky. Sleep budget total: 488.9 s → 172.3 s (-65%).
1 parent 7625661 commit 507afb0

10 files changed

Lines changed: 429 additions & 48 deletions

File tree

apps/desktop/test/e2e-playwright/accessibility.spec.ts

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -181,12 +181,11 @@ async function setTheme(tauriPage: PageLike, mode: 'dark' | 'light'): Promise<vo
181181
5000,
182182
)
183183

184-
// Force a synchronous reflow so WKWebView invalidates cached computed styles,
185-
// then sleep to give the browser time to propagate to all descendant elements.
186-
// 2s is generous but WKWebView's style cache invalidation can lag significantly
187-
// under load (the first theme switch after app launch is the worst case).
184+
// Force a synchronous reflow so WKWebView invalidates cached computed styles.
185+
// Historically a 2 s sleep followed this to wait for descendant style cache
186+
// flushing, but that mattered only for color-contrast auditing — which is
187+
// disabled in this suite. The :root pollUntil above is enough.
188188
await tauriPage.evaluate(`document.documentElement.offsetHeight`)
189-
await sleep(2000)
190189
}
191190

192191
// ── Tests ───────────────────────────────────────────────────────────────────
@@ -343,7 +342,6 @@ for (const mode of ['light', 'dark'] as const) {
343342
}
344343
}
345344
})()`)
346-
await sleep(500)
347345

348346
// Wait for the section to be visible
349347
const sectionSelector = `[data-section-id="${section.sectionId}"]`
@@ -354,9 +352,11 @@ for (const mode of ['light', 'dark'] as const) {
354352
continue
355353
}
356354

357-
// Extra settle time for sections with async data (for example, Drive indexing
355+
// Brief settle for sections with async data (for example, Drive indexing
358356
// loads dbFileSize which controls the "Clear index" button's disabled state).
359-
await sleep(500)
357+
// The pollUntil above already gated on section visibility — this just lets
358+
// any reactive child updates land before axe inspects the DOM.
359+
await sleep(150)
360360

361361
const { all } = await runAxeAudit(tauriPage, `Settings: ${section.name} (${mode})`)
362362
if (all.length > 0) {

apps/desktop/test/e2e-playwright/error-pane.spec.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,9 @@ async function injectAndNavigateIntoSubDir(tauriPage: PageLike, errorCode: numbe
5959
payload: { pane: 'left', path: ${JSON.stringify(subDirPath)} }
6060
});
6161
})()`)
62-
await sleep(2000)
62+
// Wait for the error pane to render (the injected error fires during the
63+
// background listing kicked off by the navigation above).
64+
await pollUntil(tauriPage, async () => tauriPage.evaluate<boolean>(`!!document.querySelector('.error-pane')`), 10000)
6365
}
6466

6567
/** Navigates the focused pane back to the fixture root's left/ directory. */

apps/desktop/test/e2e-playwright/file-watching.spec.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -201,8 +201,19 @@ test.describe('File watching', () => {
201201
fs.rmSync(tempDir, { recursive: true, force: true })
202202
extraPaths.length = 0 // Already removed
203203

204-
// Give the app time to react
205-
await sleep(3000)
204+
// Give the app time to react. We don't have a strict UI signal for "the
205+
// watcher noticed the parent went away", so poll until the file-pane stops
206+
// listing the temp file (which proves the listing was refreshed) or until
207+
// the timeout — then the subsequent assertions cover the "app still works"
208+
// contract regardless.
209+
await pollUntil(
210+
tauriPage,
211+
async () => {
212+
const stillThere = await fileExistsInPane(tauriPage, 'temp-file.txt', 1)
213+
return !stillThere
214+
},
215+
5000,
216+
)
206217

207218
// The app should still be functional — left pane unaffected
208219
expect(await fileExistsInPane(tauriPage, 'file-a.txt', 0)).toBe(true)

apps/desktop/test/e2e-playwright/helpers.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,6 @@ export async function ensureAppReady(
114114
// Navigate to the main route to ensure we're on the file explorer page.
115115
// This does NOT reset the directory — just ensures we're on the right route.
116116
await navigateToRoute(tauriPage, '/')
117-
await sleep(500)
118117

119118
// Wait for file entries to be visible (confirms app is fully loaded)
120119
await tauriPage.waitForSelector('.file-entry', 15000)
@@ -159,7 +158,8 @@ export async function ensureAppReady(
159158
payload: { pane: 'right', path: ${JSON.stringify(rightPanePath)} }
160159
});
161160
})()`)
162-
await sleep(300)
161+
162+
// The leftExpected file poll below covers the wait for navigation to land.
163163

164164
// Wait for the left pane to show the expected fixture files
165165
const leftExpected = expectedFiles?.leftPane ?? ['file-a.txt', 'sub-dir']
@@ -399,6 +399,13 @@ export async function countEntriesWithPrefix(tauriPage: PageLike, prefix: string
399399
// ── Utility ─────────────────────────────────────────────────────────────────
400400

401401
export function sleep(ms: number): Promise<void> {
402+
if (process.env.SLEEP_LOG === '1') {
403+
const stack = new Error().stack ?? ''
404+
const lines = stack.split('\n')
405+
// index 0 = "Error", 1 = sleep itself, 2 = caller
406+
const frame = (lines[2] ?? '').trim().slice(0, 200)
407+
process.stdout.write(`[sleep] +${String(ms)}ms @ ${frame}\n`)
408+
}
402409
return new Promise((resolve) => setTimeout(resolve, ms))
403410
}
404411

@@ -410,7 +417,7 @@ export async function pollUntil(
410417
_page: PageLike,
411418
condition: () => Promise<boolean>,
412419
timeout: number,
413-
interval = 100,
420+
interval = 50,
414421
): Promise<boolean> {
415422
const deadline = Date.now() + timeout
416423
while (Date.now() < deadline) {

apps/desktop/test/e2e-playwright/mtp-conflicts.spec.ts

Lines changed: 71 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ import {
2323
mcpAwaitItem,
2424
mcpSwitchPane,
2525
} from '../e2e-shared/mcp-client.js'
26-
import { ensureAppReady, getFixtureRoot, sleep, TRANSFER_DIALOG } from './helpers.js'
26+
import { ensureAppReady, getFixtureRoot, pollUntil, sleep, TRANSFER_DIALOG } from './helpers.js'
2727
import {
2828
waitForConflictPolicy,
2929
selectConflictPolicy,
@@ -34,6 +34,30 @@ import {
3434
const INTERNAL_STORAGE = 'Virtual Pixel 9 - Internal Storage'
3535
const LOCAL_VOLUME_NAME = os.platform() === 'linux' ? 'Root' : 'Macintosh HD'
3636

37+
/** True when both panes show the local volume in cmdr://state. */
38+
async function bothPanesOnLocalVolume(): Promise<boolean> {
39+
const state = await mcpReadResource('cmdr://state')
40+
const volumeLines = (state.match(/\n {2}volume: ([^\n]+)/g) ?? []).map((line) => line.replace(/^\n {2}volume: /, ''))
41+
return volumeLines.length >= 2 && volumeLines[0] === LOCAL_VOLUME_NAME && volumeLines[1] === LOCAL_VOLUME_NAME
42+
}
43+
44+
/**
45+
* Polls a sync filesystem predicate until it returns true or timeout is reached.
46+
* Used to wait for MTP / cross-volume operations to settle on disk.
47+
*/
48+
async function pollFs(
49+
tauriPage: Parameters<typeof pollUntil>[0],
50+
predicate: () => boolean,
51+
timeoutMs = 15000,
52+
): Promise<boolean> {
53+
return pollUntil(
54+
tauriPage,
55+
// eslint-disable-next-line @typescript-eslint/require-await -- pollUntil expects a Promise<boolean>
56+
async () => predicate(),
57+
timeoutMs,
58+
)
59+
}
60+
3761
/** Discovers the mtp:// path prefix for a named MTP storage from cmdr://state. */
3862
async function getMtpVolumePath(storageName: string): Promise<string> {
3963
const state = await mcpReadResource('cmdr://state')
@@ -67,11 +91,10 @@ test.beforeEach(async ({ tauriPage }) => {
6791
invoke('plugin:event|emit', { event: 'mcp-volume-select', payload: { pane: 'left', name: '${LOCAL_VOLUME_NAME}' } });
6892
invoke('plugin:event|emit', { event: 'mcp-volume-select', payload: { pane: 'right', name: '${LOCAL_VOLUME_NAME}' } });
6993
})()`)
70-
await sleep(2000)
94+
await pollUntil(tauriPage, async () => bothPanesOnLocalVolume(), 5000)
7195
await tauriPage.keyboard.press('Escape')
72-
await sleep(200)
7396
await tauriPage.keyboard.press('Escape')
74-
await sleep(200)
97+
await pollUntil(tauriPage, async () => !(await tauriPage.isVisible('.modal-overlay')), 2000)
7598
})
7699

77100
// ── Cross-volume move conflicts (MTP ↔ local) ──────────────────────────────
@@ -100,15 +123,25 @@ test.describe('MTP cross-volume move conflicts', () => {
100123
await clickTransferStart(tauriPage)
101124
await waitForDialogsToClose(tauriPage, 30000)
102125

103-
// Wait for MTP operation
104-
await sleep(3000)
126+
// Wait for the MTP operation to settle on disk: dest contains MTP content AND source removed.
127+
const destPath = path.join(fixtureRoot, 'right', 'report.txt')
128+
const srcPath = path.join(MTP_FIXTURE_ROOT, 'internal', 'Documents', 'report.txt')
129+
await pollFs(
130+
tauriPage,
131+
() => {
132+
if (fs.existsSync(srcPath)) return false
133+
if (!fs.existsSync(destPath)) return false
134+
return fs.readFileSync(destPath, 'utf-8').includes('Quarterly report')
135+
},
136+
15000,
137+
)
105138

106139
// Dest should have MTP content (overwritten)
107-
const destContent = fs.readFileSync(path.join(fixtureRoot, 'right', 'report.txt'), 'utf-8')
140+
const destContent = fs.readFileSync(destPath, 'utf-8')
108141
expect(destContent).toContain('Quarterly report')
109142

110143
// Source should be deleted from MTP
111-
expect(fs.existsSync(path.join(MTP_FIXTURE_ROOT, 'internal', 'Documents', 'report.txt'))).toBe(false)
144+
expect(fs.existsSync(srcPath)).toBe(false)
112145
})
113146

114147
test('MTP-to-local move with skip preserves both files', async ({ tauriPage }) => {
@@ -132,8 +165,6 @@ test.describe('MTP cross-volume move conflicts', () => {
132165
await clickTransferStart(tauriPage)
133166
await waitForDialogsToClose(tauriPage, 30000)
134167

135-
await sleep(3000)
136-
137168
// Dest unchanged (skip)
138169
const destContent = fs.readFileSync(path.join(fixtureRoot, 'right', 'report.txt'), 'utf-8')
139170
expect(destContent).toBe('local-version')
@@ -164,15 +195,27 @@ test.describe('MTP cross-volume move conflicts', () => {
164195
await clickTransferStart(tauriPage)
165196
await waitForDialogsToClose(tauriPage, 30000)
166197

167-
await sleep(3000)
198+
// Wait for the move to settle on disk: MTP file has overwritten content AND source removed.
199+
const mtpDest = path.join(MTP_FIXTURE_ROOT, 'internal', 'file-a.txt')
200+
const localSrc = path.join(fixtureRoot, 'left', 'file-a.txt')
201+
const expectedContent = 'A'.repeat(1024)
202+
await pollFs(
203+
tauriPage,
204+
() => {
205+
if (fs.existsSync(localSrc)) return false
206+
if (!fs.existsSync(mtpDest)) return false
207+
return fs.readFileSync(mtpDest, 'utf-8') === expectedContent
208+
},
209+
15000,
210+
)
168211
await mcpCall('refresh', {})
169212

170213
// MTP file should have local content (overwritten) — local fixture is 1024 'A' chars
171-
const mtpContent = fs.readFileSync(path.join(MTP_FIXTURE_ROOT, 'internal', 'file-a.txt'), 'utf-8')
172-
expect(mtpContent).toBe('A'.repeat(1024))
214+
const mtpContent = fs.readFileSync(mtpDest, 'utf-8')
215+
expect(mtpContent).toBe(expectedContent)
173216

174217
// Local source should be gone (moved)
175-
expect(fs.existsSync(path.join(fixtureRoot, 'left', 'file-a.txt'))).toBe(false)
218+
expect(fs.existsSync(localSrc)).toBe(false)
176219
})
177220
})
178221

@@ -212,15 +255,26 @@ test.describe('MTP same-volume move conflicts', () => {
212255
await clickTransferStart(tauriPage)
213256
await waitForDialogsToClose(tauriPage, 30000)
214257

215-
await sleep(3000)
258+
// Wait for the same-volume MTP move to settle on disk.
259+
const rootPath = path.join(MTP_FIXTURE_ROOT, 'internal', 'report.txt')
260+
const docsSrc = path.join(MTP_FIXTURE_ROOT, 'internal', 'Documents', 'report.txt')
261+
await pollFs(
262+
tauriPage,
263+
() => {
264+
if (fs.existsSync(docsSrc)) return false
265+
if (!fs.existsSync(rootPath)) return false
266+
return fs.readFileSync(rootPath, 'utf-8').includes('Quarterly report')
267+
},
268+
15000,
269+
)
216270
await mcpCall('refresh', {})
217271

218272
// Root report.txt should have Documents content (overwritten)
219-
const rootContent = fs.readFileSync(path.join(MTP_FIXTURE_ROOT, 'internal', 'report.txt'), 'utf-8')
273+
const rootContent = fs.readFileSync(rootPath, 'utf-8')
220274
expect(rootContent).toContain('Quarterly report')
221275

222276
// Source should be gone from Documents
223-
expect(fs.existsSync(path.join(MTP_FIXTURE_ROOT, 'internal', 'Documents', 'report.txt'))).toBe(false)
277+
expect(fs.existsSync(docsSrc)).toBe(false)
224278
})
225279

226280
test('same-volume MTP move with skip preserves both files', async ({ tauriPage }) => {
@@ -253,8 +307,6 @@ test.describe('MTP same-volume move conflicts', () => {
253307
await clickTransferStart(tauriPage)
254308
await waitForDialogsToClose(tauriPage, 30000)
255309

256-
await sleep(3000)
257-
258310
// Root file unchanged (skip)
259311
const rootContent = fs.readFileSync(path.join(MTP_FIXTURE_ROOT, 'internal', 'report.txt'), 'utf-8')
260312
expect(rootContent).toBe('root-version')

apps/desktop/test/e2e-playwright/mtp.spec.ts

Lines changed: 44 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,25 @@ const SD_CARD = 'Virtual Pixel 9 - SD Card'
7070
// Local volume name differs by platform (macOS: "Macintosh HD", Linux: "Root")
7171
const LOCAL_VOLUME_NAME = os.platform() === 'linux' ? 'Root' : 'Macintosh HD'
7272

73+
/** Returns the size of a file, or -1 if it doesn't exist / can't be statted. */
74+
function safeFileSize(p: string): number {
75+
try {
76+
return fs.statSync(p).size
77+
} catch {
78+
return -1
79+
}
80+
}
81+
82+
/**
83+
* Reads cmdr://state and returns true when both panes show the local volume.
84+
* The state YAML has `left:` and `right:` blocks each containing a ` volume: NAME` line.
85+
*/
86+
async function bothPanesOnLocalVolume(): Promise<boolean> {
87+
const state = await mcpReadResource('cmdr://state')
88+
const volumeLines = (state.match(/\n {2}volume: ([^\n]+)/g) ?? []).map((line) => line.replace(/^\n {2}volume: /, ''))
89+
return volumeLines.length >= 2 && volumeLines[0] === LOCAL_VOLUME_NAME && volumeLines[1] === LOCAL_VOLUME_NAME
90+
}
91+
7392
/**
7493
* Discovers the mtp:// path prefix for a named MTP storage from cmdr://state.
7594
* The device ID is assigned at runtime, so tests must discover it dynamically.
@@ -114,13 +133,13 @@ test.beforeEach(async ({ tauriPage }) => {
114133
invoke('plugin:event|emit', { event: 'mcp-volume-select', payload: { pane: 'left', name: '${LOCAL_VOLUME_NAME}' } });
115134
invoke('plugin:event|emit', { event: 'mcp-volume-select', payload: { pane: 'right', name: '${LOCAL_VOLUME_NAME}' } });
116135
})()`)
117-
await sleep(2000) // Wait for volume switches to complete
136+
// Wait for both panes to show the local volume.
137+
await pollUntil(tauriPage, async () => bothPanesOnLocalVolume(), 5000)
118138

119139
// Dismiss any lingering dialogs/overlays from previous tests
120140
await tauriPage.keyboard.press('Escape')
121-
await sleep(200)
122141
await tauriPage.keyboard.press('Escape')
123-
await sleep(200)
142+
await pollUntil(tauriPage, async () => !(await tauriPage.isVisible('.modal-overlay')), 2000)
124143
})
125144

126145
// ── Tests ────────────────────────────────────────────────────────────────────
@@ -932,14 +951,21 @@ test.describe('MTP large file transfer', () => {
932951
await mcpCall('move_cursor', { pane: 'left', filename: 'large-test.dat' })
933952
await mcpCall('copy', { autoConfirm: true })
934953

935-
// Large file transfer through MTP protocol stack takes longer
936-
await sleep(10000)
954+
// Poll until the destination file reaches the expected size (50 MB).
955+
const expectedSize = 50 * 1024 * 1024
956+
const destPath = path.join(MTP_FIXTURE_ROOT, 'internal', 'large-test.dat')
957+
const transferred = await pollUntil(
958+
tauriPage,
959+
() => Promise.resolve(safeFileSize(destPath) === expectedSize),
960+
30000,
961+
)
962+
expect(transferred).toBe(true)
937963
await mcpCall('refresh', {})
938964
await mcpAwaitItem('right', 'large-test.dat', 60)
939965

940966
// Verify file size in MTP backing dir
941-
const stat = fs.statSync(path.join(MTP_FIXTURE_ROOT, 'internal', 'large-test.dat'))
942-
expect(stat.size).toBe(50 * 1024 * 1024)
967+
const stat = fs.statSync(destPath)
968+
expect(stat.size).toBe(expectedSize)
943969
})
944970

945971
test('copies 50 MB file from MTP to local', async ({ tauriPage }) => {
@@ -965,12 +991,20 @@ test.describe('MTP large file transfer', () => {
965991
await mcpCall('move_cursor', { pane: 'left', filename: 'large-mtp.dat' })
966992
await mcpCall('copy', { autoConfirm: true })
967993

968-
await sleep(10000)
994+
// Poll until the destination file reaches the expected size (50 MB).
995+
const expectedSize = 50 * 1024 * 1024
996+
const destPath = path.join(fixtureRoot, 'right', 'large-mtp.dat')
997+
const transferred = await pollUntil(
998+
tauriPage,
999+
() => Promise.resolve(safeFileSize(destPath) === expectedSize),
1000+
30000,
1001+
)
1002+
expect(transferred).toBe(true)
9691003
await mcpCall('refresh', {})
9701004
await mcpAwaitItem('right', 'large-mtp.dat', 60)
9711005

9721006
// Verify file size on local disk
973-
const stat = fs.statSync(path.join(fixtureRoot, 'right', 'large-mtp.dat'))
974-
expect(stat.size).toBe(50 * 1024 * 1024)
1007+
const stat = fs.statSync(destPath)
1008+
expect(stat.size).toBe(expectedSize)
9751009
})
9761010
})

apps/desktop/test/e2e-playwright/network-toggle.spec.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
import os from 'node:os'
2020
import { test, expect } from './fixtures.js'
2121
import { ensureAppReady, pollUntil, sleep } from './helpers.js'
22+
import { initMcpClient, mcpReadResource } from '../e2e-shared/mcp-client.js'
2223

2324
// Volume name for "Macintosh HD" on macOS / "Root" on Linux. We force both panes back to
2425
// this volume in `beforeEach` so the spec runs cleanly even when a prior MTP test left a
@@ -87,7 +88,19 @@ test.describe('Network toggle in volume picker', () => {
8788
invoke('plugin:event|emit', { event: 'mcp-volume-select', payload: { pane: 'left', name: '${LOCAL_VOLUME_NAME}' } });
8889
invoke('plugin:event|emit', { event: 'mcp-volume-select', payload: { pane: 'right', name: '${LOCAL_VOLUME_NAME}' } });
8990
})()`)
90-
await sleep(2000)
91+
// Wait for both panes to actually be on the local volume before asserting picker UX.
92+
await initMcpClient(tauriPage)
93+
await pollUntil(
94+
tauriPage,
95+
async () => {
96+
const state = await mcpReadResource('cmdr://state')
97+
const volumeLines = (state.match(/\n {2}volume: ([^\n]+)/g) ?? []).map((line) =>
98+
line.replace(/^\n {2}volume: /, ''),
99+
)
100+
return volumeLines.length >= 2 && volumeLines[0] === LOCAL_VOLUME_NAME && volumeLines[1] === LOCAL_VOLUME_NAME
101+
},
102+
5000,
103+
)
91104

92105
await ensureAppReady(tauriPage)
93106

0 commit comments

Comments
 (0)