Skip to content

Commit 1651dda

Browse files
committed
test(org): screenshot a page only once its first reads have finished
A quiet network passes before a lazy section has loaded enough to send its read, so a green walk could be a set of skeletons. A route now waits for no read and no loading status or busy region on screen, fails at a deadline naming what persists, and reports how long it took.
1 parent 91588b9 commit 1651dda

4 files changed

Lines changed: 61 additions & 7 deletions

File tree

‎apps/sim/scripts/org-redesign-e2e/checks/people.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,7 @@ async function walkPeopleRoutes() {
141141
]) {
142142
const response = await session.page.goto(orgUrl(`/${path}`))
143143
assert((response?.status() ?? 500) < 400, `${path} ${response?.status()}`)
144-
await session.settle()
144+
await session.contentReady()
145145
await expect(
146146
session.page.getByRole('button', { name: 'Org chart' }),
147147
`${path} rendered no People page`

‎apps/sim/scripts/org-redesign-e2e/checks/screenshots.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,7 @@ export async function stateScreenshots() {
142142
const response = await session.page.goto(orgUrl(`/${shot.path}`), {
143143
timeout: 60_000,
144144
})
145-
await session.settle()
145+
await session.contentReady()
146146
if (shot.name === 'not-found') await expectPageNotFound(session.page)
147147
else
148148
assert(

‎apps/sim/scripts/org-redesign-e2e/checks/walk.ts‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -110,9 +110,10 @@ async function walkOne(persona: Persona, viewport: Viewport, theme: Theme) {
110110
if (persona === CONNECTED) await stubConnectedHome(page)
111111
for (const route of routesFor(persona)) {
112112
const response = await page.goto(orgUrl(`/${route.path}`), { timeout: 60_000 })
113-
await session
114-
.settle()
115-
.catch((error) => failures.push(`${route.name}: ${getErrorMessage(error)}`))
113+
const contentReadyMs = await session.contentReady().catch((error) => {
114+
failures.push(`${route.name}: ${getErrorMessage(error)}`)
115+
return null
116+
})
116117
if (route.name === 'home')
117118
await composer(page)
118119
.waitFor({ timeout: 20_000 })
@@ -149,6 +150,7 @@ async function walkOne(persona: Persona, viewport: Viewport, theme: Theme) {
149150
finalPath: pathOf(page.url()),
150151
screenshot,
151152
platformSurfaces: facts.surfaces,
153+
contentReadyMs,
152154
})
153155
if ((response?.status() ?? 500) >= 400)
154156
failures.push(`${route.name} status ${response?.status()}`)

‎apps/sim/scripts/org-redesign-e2e/harness.ts‎

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,8 @@ export const routes: {
107107
finalPath: string
108108
screenshot: string | null
109109
platformSurfaces: number
110+
/** From the document's load to no read or skeleton left, or null if it never got there. */
111+
contentReadyMs: number | null
110112
}[] = []
111113
export const firstPaints: FirstPaint[] = []
112114
const unexpected: Problem[] = []
@@ -131,6 +133,11 @@ const ENVIRONMENT_CONSOLE_NOISE = [
131133
/^The Cross-Origin-Opener-Policy header has been ignored, because the URL's origin was untrustworthy\./,
132134
/^Access to fetch at 'https:\/\/sim-sim\.inth\.app\/init' from origin /,
133135
]
136+
/** How long a route may keep showing a first read before the walk fails it as stuck. */
137+
const CONTENT_DEADLINE_MS = 20_000
138+
/** The gap between the two looks that must agree: the next read starts after a chunk or a mount. */
139+
const CONTENT_RECHECK_MS = 300
140+
const LOADING_STATUS = /^(Loading|Checking|Searching|Updating)\b/
134141
const HIGH_VOLUME_REQUEST = /\/api\/mothership\/events|\/socket\.io\/|\/api\/auth\/socket-token/
135142

136143
const browser: Browser = await chromium.launch()
@@ -142,6 +149,24 @@ interface CheckScope {
142149
}
143150
const checkScope = new AsyncLocalStorage<CheckScope>()
144151

152+
/**
153+
* The loading states on screen, named. Runs in the page. Skeletons announce themselves as a
154+
* `role=status` saying what they load or an `aria-busy` region, which is also what a screen reader
155+
* gets, so no check depends on a skeleton's class.
156+
*/
157+
function loadingStatesOnScreen(pattern: string) {
158+
const loading = new RegExp(pattern)
159+
const named = (element: Element) =>
160+
element.getAttribute('aria-label') || element.textContent?.trim() || element.tagName
161+
const busy = Array.from(document.querySelectorAll('[aria-busy="true"]'))
162+
const statuses = Array.from(document.querySelectorAll('[role="status"]')).filter((element) =>
163+
loading.test(named(element))
164+
)
165+
return [...new Set([...busy, ...statuses])]
166+
.filter((element) => element.checkVisibility({ visibilityProperty: true }))
167+
.map(named)
168+
}
169+
145170
function reportCounts() {
146171
const counts = { passed: 0, failed: 0, skipped: 0 }
147172
for (const entry of checks) counts[entry.status] += 1
@@ -318,11 +343,38 @@ class Session {
318343
if (this.inflight.size === 0 && Date.now() - this.lastActivity > 400) return
319344
await sleep(100)
320345
}
321-
const out = [...this.inflight].map((request) => `${request.method()} ${request.url()}`)
322346
throw new Error(
323-
`The page did not go quiet in ${maxMs}ms. Still out: ${out.join(', ') || 'none'}`
347+
`The page did not go quiet in ${maxMs}ms. Still out: ${this.readsOut() || 'none'}`
348+
)
349+
}
350+
351+
/**
352+
* Quiet, and past every first read: a quiet network alone passes before a lazy section has loaded
353+
* enough to send its read, with its skeleton on screen. A state a check holds on purpose is that
354+
* check's to assert; this is for a page about to be read as finished. Returns how long it took.
355+
*/
356+
async contentReady(maxMs = CONTENT_DEADLINE_MS) {
357+
const started = Date.now()
358+
const loading = () => this.page.evaluate(loadingStatesOnScreen, LOADING_STATUS.source)
359+
let showing: string[] = []
360+
while (Date.now() - started < maxMs) {
361+
await this.settle(maxMs - (Date.now() - started)).catch(() => {})
362+
showing = await loading()
363+
if (showing.length === 0) {
364+
await sleep(CONTENT_RECHECK_MS)
365+
showing = await loading()
366+
if (showing.length === 0 && this.inflight.size === 0) return Date.now() - started
367+
}
368+
await sleep(100)
369+
}
370+
throw new Error(
371+
`Still loading after ${maxMs}ms: ${showing.join(', ') || 'no skeleton'}. Reads still out: ${this.readsOut() || 'none'}`
324372
)
325373
}
374+
375+
private readsOut() {
376+
return [...this.inflight].map((request) => `${request.method()} ${request.url()}`).join(', ')
377+
}
326378
}
327379

328380
async function cookieFor(persona: Persona) {

0 commit comments

Comments
 (0)