diff --git a/demo/benchmark_large_house.mjs b/demo/benchmark_large_house.mjs index 6ceaf3ec..bf49faae 100644 --- a/demo/benchmark_large_house.mjs +++ b/demo/benchmark_large_house.mjs @@ -13,6 +13,7 @@ import { assertIsometricCandidate, LARGE_HOUSE_CARD_CONTRACT, } from './performance/card-contract.mjs'; +import { attributeResizeLongTask } from './performance/resize-attribution.mjs'; import { ISOMETRIC_STAGE3_DENSE_PROFILE, makeIsometricStage3DenseFixture, @@ -106,6 +107,9 @@ await page.addScriptTag({ await page.addScriptTag({ content: `window.__hpEnsureHarnessEditorRuntime = ${ensureHarnessEditorRuntime.toString()};`, }); +await page.addScriptTag({ + content: `window.__hpAttributeResizeLongTask = ${attributeResizeLongTask.toString()};`, +}); const chromium = await browser.version(); let buildFingerprint; try { @@ -135,11 +139,17 @@ try { const startLongTaskWindow = () => { const entries = []; if (!PerformanceObserver.supportedEntryTypes?.includes('longtask')) { - return { stop: async () => ({ supported: false, count: 0, maxMs: 0, totalMs: 0 }) }; + return { + entries: null, + stop: async () => ({ supported: false, count: 0, maxMs: 0, totalMs: 0 }), + }; } const observer = new PerformanceObserver((list) => entries.push(...list.getEntries())); observer.observe({ type: 'longtask', buffered: false }); return { + // #778: the raw entries stay out of the report; the resize + // attribution reads exactly the task this window judged. + entries, stop: async () => { await new Promise((done) => setTimeout(done, 0)); entries.push(...observer.takeRecords()); @@ -173,6 +183,12 @@ try { throw new Error(`${profile} isometric runtime did not load`); await card.updateComplete; }; + // #778: phase spans of the resize part's Long Task. Null outside that + // part, so every other window runs the wrappers below as before. + let attributionSpans = null; + const recordSpan = (name, startMs) => { + attributionSpans?.push({ name, startMs, endMs: performance.now() }); + }; const forceGc = async () => { if (typeof globalThis.gc !== 'function') return false; globalThis.gc(); @@ -349,6 +365,7 @@ try { const result = diagPerform(); card.__diag.updates += 1; card.__diag.updateMs += performance.now() - started; + recordSpan('update', started); return result; }; const diagBuild = card._buildModel.bind(card); @@ -470,7 +487,10 @@ try { physicalPreflightCount++; const started = performance.now(); try { return checkSpacePhysicalGeometry(...args); } - finally { physicalPreflightMs += performance.now() - started; } + finally { + physicalPreflightMs += performance.now() - started; + recordSpan('preflight', started); + } }; const willUpdate = card.willUpdate.bind(card); card.willUpdate = (changed) => { @@ -867,6 +887,68 @@ try { editorStage = card.renderRoot.querySelector('.stage'); editorRect = editorStage.getBoundingClientRect(); editorView = card._viewOr(card._baseVb()); + // #778: split this part's Long Task into timed phases (see + // performance/resize-attribution.mjs). The wrappers only time the live + // move and its callbacks, and they and the frame observer are installed + // before the yield below, so neither is inside the measured task. + const startResizeAttribution = () => { + const controller = card._resize; + const move = controller?.move; + if (typeof move !== 'function') + return { stop: () => ({ supported: false, reason: 'no ResizeController move' }) }; + const spans = []; + const frames = []; + let shapeError = null; + const frameObserver = PerformanceObserver.supportedEntryTypes?.includes('long-animation-frame') + ? new PerformanceObserver((list) => frames.push(...list.getEntries())) : null; + frameObserver?.observe({ type: 'long-animation-frame', buffered: false }); + const timed = (name, run) => function (...args) { + const started = performance.now(); + try { return run.apply(this, args); } finally { recordSpan(name, started); } + }; + const ownMove = Object.prototype.hasOwnProperty.call(controller, 'move'); + controller.move = function (input) { + if (!['project', 'publish', 'measure'].every((key) => typeof input?.[key] === 'function')) { + // An unknown move shape is reported, never turned into zero shares. + shapeError = 'ResizeController.move input has no project/publish/measure callbacks'; + return move.call(this, input); + } + return timed('move', move).call(this, { + ...input, + project: timed('project', input.project), + publish: timed('publish', input.publish), + measure: timed('labels', input.measure), + }); + }; + attributionSpans = spans; + return { + stop: (longTasks) => { + frames.push(...(frameObserver?.takeRecords() ?? [])); + frameObserver?.disconnect(); + if (ownMove) controller.move = move; + else delete controller.move; + attributionSpans = null; + if (shapeError) return { supported: false, reason: shapeError }; + return window.__hpAttributeResizeLongTask({ + longTasks: longTasks?.map((entry) => ({ + startTime: entry.startTime, duration: entry.duration, + })) ?? null, + spans, + frames: frameObserver ? frames.map((entry) => ({ + startTime: entry.startTime, + duration: entry.duration, + renderStart: entry.renderStart, + scripts: [...(entry.scripts || [])].map((script) => ({ + startTime: script.startTime, + duration: script.duration, + forcedStyleAndLayoutDuration: script.forcedStyleAndLayoutDuration, + })), + })) : null, + }); + }, + }; + }; + const resizeAttribution = startResizeAttribution(); // A browser delivers pointerdown and pointermove as separate tasks. // Keep the synthetic harness from attributing both handlers to one // impossible long task while still timing every move-side preflight. @@ -896,6 +978,7 @@ try { editorTerminalRenders.push(fullRenderCount - editorTerminalBefore); editorElapsed += performance.now() - partStarted; editorLongTaskWindows.push(await editorPartLongTasks.stop()); + deltas.resizeLongTask = resizeAttribution.stop(editorPartLongTasks.entries); card._setMode('decor', false); card._decorTool = 'select'; @@ -1312,6 +1395,22 @@ try { + ` epochs=[${(bootDiag.epochs || []).join(' ; ')}]` + ` modelReady=${measured.modelReadyMs} firstStable=${measured.firstStableRenderMs}`); } + // #778: the resize Long Task split, printed for the job log; the same + // numbers stay in the record as interactionDiagnostics.resizeLongTask. + const resizeLongTask = measured.interactionDiagnostics?.resizeLongTask; + if (resizeLongTask?.supported && resizeLongTask.longTaskMs > 0) { + const part = (name, value) => `${name} ${value ?? '—'}`; + console.log(`#778 resize long task sample ${row.sample}: ${resizeLongTask.longTaskMs} ms = ${[ + part('preflight', resizeLongTask.preflightMs), part('project', resizeLongTask.projectOtherMs), + part('publish', resizeLongTask.publishMs), part('labels', resizeLongTask.labelsMs), + part('move', resizeLongTask.moveOtherMs), part('update', resizeLongTask.updateMs), + part('other', resizeLongTask.otherMs), + ].join(' + ')} (geometry moves ${resizeLongTask.geometryMoves}; after the task:` + + ` render ${resizeLongTask.frameRenderMs ?? '—'} ms; forced style/layout in it` + + ` ${resizeLongTask.forcedStyleLayoutMs ?? '—'} ms)`); + } else if (resizeLongTask && !resizeLongTask.supported) { + console.log(`#778 resize long task sample ${row.sample}: not attributed (${resizeLongTask.reason})`); + } if (measuredSample >= 0) rows.push(measured); } } finally { diff --git a/demo/performance/README.md b/demo/performance/README.md index e32745f2..3c844352 100644 --- a/demo/performance/README.md +++ b/demo/performance/README.md @@ -315,6 +315,90 @@ parent. An unusable requested revision falls back with a warning to the direct parent, then to the newest reachable semver release. If no safe comparison exists, the job fails closed instead of comparing against an arbitrary commit. +## Resize Long Task of the interaction profile (#778) + +`longTask.editorSeries.maxSingleMs` (150 ms in both the full and the smoke +interaction budgets) is the longest task of three editor parts; in every +recorded CI sample it is the resize part, and that part has exactly one Long +Task. The Validate smoke of #762 (`adc2d7c5`, run 36910217188) read 135 / 129 / +**163** ms on its first attempt and 127 / 132 / 132 ms on the re-run of the +same job, without any change. + +**What the window holds.** The part dispatches 40 moves, ten per frame. The +pointer-move queue keeps only the last move of a task and the Safe Resize clamp +allows a single grid step in this fixture, so exactly one move per sample +projects a candidate (`resizeLivePreflightChecks` is 1 in every sample). Its +task is the Long Task: the timer continuation that dispatches the batch, then +`ResizeController.move` with the live physical preflight inside `project`, the +`publish` of the preview and the live `labels` (`measure`). A Chrome trace and +Long Animation Frame entries of local runs show no Lit update, no forced style or +layout and no paint in that task; the live editor paint and the frame's style, +layout and paint follow in the next rendering step (6–15 ms locally) and are not +part of it. Garbage collection inside the traced task was four minor scavenges, +about 4 % of it, and no major collection. This is the work of a real first +resize step: a browser delivers each pointer move as its own task, and its +handler queues the same move microtask; pointerdown stays in an earlier task +(the runner's yield before the window). The window therefore measures a real, +stable task, and no measurement defect was found. + +**The CI series.** Fourteen Full Performance runs with the interaction profile +(2026-10-01 to 10-05, Chromium 151.0.7922.34, 28 reports of 7 samples) plus both +attempts of the Validate smoke above give 202 samples, one of them above 150 ms. + +| | Full Performance (196) | Validate attempt 1 (3) | Validate attempt 2 (3) | +|---|---|---|---| +| resize Long Task, ms | 64–146; report medians 114–142 on the usual runners, 97–100 and 65–68 on two faster ones | 135 / 129 / 163 | 127 / 132 / 132 | +| physical preflight, ms | 44.0–106.9 | 94.6 / 89.5 / 97.8 | 86.9 / 93.0 / 94.3 | +| task minus preflight, ms | 20.0–46.9, within-report σ ≤ 2.2 | 40.4 / 39.5 / **65.2** | 40.1 / 39.0 / 37.7 | + +The preflight is 60–75 % of the task (median 69 %) and the ratio of task to +preflight is 1.34–1.54 everywhere except the failed sample (1.67): the +composition is constant and scales with the runner. Runner classes differ by up +to 2× (the same `24e48935c` read 124, 65, 129 and 114 ms in four runs), while +one report varies by σ 1–5 ms. Base and candidate on one runner differ by −5 % to ++9 % (`24671d435` of #780 against its base; the step did not persist). The +failed sample spent a normal 97.8 ms in the preflight and 65.2 ms elsewhere: +25 ms above its siblings, 11–20 times the within-report σ of that remainder and +18 ms above the remainder of any other sample. The same sample also had an +extra Long Task in its load (3 against 2) and space-switch (2 against 1) +windows, `editorSeriesMs` +46 ms and `irrelevantHaTicksMs` +17 ms. The verdict +is one slow sample of the whole page, not a longer resize: runner noise on top +of a systematic level of 112–146 ms per sample on the usual runners (report +medians 114–142 ms), 3–25 % under the ceiling. One exceedance in 202 samples +bounds the per-sample rate below 2.7 % (95 %, Clopper–Pearson); a three-sample +smoke judges its maximum, so its failure rate is about three times the +per-sample rate. The other five #762 samples (127–135 ms) lie inside that +series, so nothing points to a regression of #762; a same-runner base and +candidate pair for it was not recorded. The ceiling is unchanged here; the +headroom is the budget question of #770. + +**Attribution in every report.** Since #778 each interaction row carries +`interactionDiagnostics.resizeLongTask`, computed by +`resize-attribution.mjs` from the entries of the very window the gate judges +and from timed wrappers that the runner installs before the yield: the longest +task (`longTaskMs`, equal to the resize part of `editorLongTaskParts`), +`geometryMoves` inside it, and its milliseconds split into `preflightMs`, +`projectOtherMs` (projection without the preflight: wall rekey, junction +limits), `publishMs`, `labelsMs`, `moveOtherMs` (snap, clamp, solver), +`updateMs` (a Lit update in the task) and `otherMs` (the rest: synthetic event +dispatch, scheduling, collection outside the timed calls; a collection that +interrupts a timed call stays in that call's phase). Long Task durations are +whole milliseconds, so the parts add up within about 1 ms and `otherMs` may be +slightly negative. `frameRenderMs` and `forcedStyleLayoutMs` come from Long +Animation Frames and are `null` without them. The runner also prints one +`#778 resize long task` line per sample. A base without +`ResizeController.move` and its `project`/`publish`/`measure` callbacks (v1.68.1) +reports `supported: false` with a reason instead of zero shares. + +Reading the next failure: extra time in `preflightMs` or `labelsMs` is product +work (or a collection while it ran) and grows with the data; extra time in +`otherMs` lies outside every timed call; `geometryMoves: 0` means the judged +task was not the resize step at all. Local runs (Chromium 141, diagnostic only) +split the task as about 70 % preflight, 24 % labels, 3 % projection, 1 % +publish, 1 % move and under 1 % other; a synthetic 30 ms stall placed before the move +moved `otherMs` by 30.8 ms and the same stall inside `measure` moved `labelsMs` +by about 28 ms, the other phases staying put. + ## Private card contract The candidate benchmark runner is also executed against the base bundle, so @@ -332,6 +416,9 @@ optional member exists, its declared `fieldTypes` contract still applies. Add a new safely degradable field to `optionalFields` until every supported base has it, then promote it to `fields`. A member without a truthful fallback must be introduced through a compatibility revision before the benchmark consumes it. +`optionalMethodsOf` declares methods of a declared member that the runner wraps +(since #778, `_resize.move` for the resize attribution): optional, but a +present one must be a function. Rename a consumed private member in two revisions: diff --git a/demo/performance/card-contract.mjs b/demo/performance/card-contract.mjs index b2586ec0..5973f403 100644 --- a/demo/performance/card-contract.mjs +++ b/demo/performance/card-contract.mjs @@ -76,6 +76,12 @@ export const LARGE_HOUSE_CARD_CONTRACT = Object.freeze({ // them (`typeof card._setProjection === 'function'`); the current card no // longer has them, so they are declared but never required. legacyOnlyFields: Object.freeze(['_setProjection']), + // #778: the resize Long Task attribution wraps the session controller's + // `move` (with its `project`/`publish`/`measure` callbacks) for the time of + // one editor part. Optional: a base without it (v1.68.1 keeps `_rszDrag`) + // or with another callback shape reports `resizeLongTask.supported: false` + // and a reason, never zero shares. A present member must be a function. + optionalMethodsOf: Object.freeze({ _resize: Object.freeze(['move']) }), // #380: v1.68.1 owns the same resize session directly on the card; newer // bundles moved it into ResizeController. A comparison target must expose // one of the two explicit shapes; the current member retains its object @@ -205,6 +211,13 @@ export function assertCardContract(card, contract) { .filter((name) => name in card && contract.fieldTypes?.[name] && !matches(card[name], contract.fieldTypes[name])) .map((name) => `${name}:${contract.fieldTypes[name]}`); + for (const [owner, names] of Object.entries(contract.optionalMethodsOf || {})) { + const target = card[owner]; + if (!target || typeof target !== 'object') continue; + for (const name of names) { + if (name in target && typeof target[name] !== 'function') invalidFields.push(`${owner}.${name}:function`); + } + } const missing = [...missingMethods, ...missingFields, ...missingAlternatives]; if (missing.length || invalidFields.length) { const details = [ diff --git a/demo/performance/resize-attribution.mjs b/demo/performance/resize-attribution.mjs new file mode 100644 index 00000000..aeaa7f2c --- /dev/null +++ b/demo/performance/resize-attribution.mjs @@ -0,0 +1,96 @@ +/** + * #778: what the Long Task of the interaction profile's resize part is made of. + * + * `longTask.editorSeries.maxSingleMs` judges one number per sample. Its resize + * part is, in every recorded CI sample, the single task of the first accepted + * pointer move of a Safe Resize drag. This function splits that task into the + * phases the runner timed inside it, so a failed run explains itself without a + * new investigation. + * + * Input (all times are `performance.now()` milliseconds of one page): + * - `longTasks`: the entries of the very Long Task window the gate judges + * (`{ startTime, duration }`), or `null` when the browser has no Long Tasks; + * - `spans`: `{ name, startMs, endMs }` recorded by the runner's wrappers — + * `move` (ResizeController.move), its callbacks `project`, `publish` and + * `labels` (`measure`), `preflight` (`_checkSpacePhysicalGeometry`, nested + * in `project` on the live path) and `update` (Lit `performUpdate`); + * - `frames`: Long Animation Frame entries reduced to `{ startTime, duration, + * renderStart, scripts: [{ startTime, duration, forcedStyleAndLayoutDuration }] }`, + * or `null` when the browser has no LoAF. + * + * The longest task of the window is the one the gate judged. Only spans that + * lie inside it are attributed to it; a task that holds no resize move reports + * `geometryMoves: 0` and all of its time as `otherMs` instead of borrowing the + * move's phases from a neighbouring task. `otherMs` is the task minus every + * timed phase: the harness's synthetic event dispatch, scheduling and any + * garbage collection outside the timed calls (a collection that interrupts a + * timed call stays inside that call's phase). Chromium reports Long Task + * durations in whole milliseconds, so the parts sum to `longTaskMs` within + * about one millisecond and `otherMs` may be slightly negative. + * + * Self-contained on purpose: the runner injects it with `toString()`. + */ +export function attributeResizeLongTask({ longTasks, spans, frames = null }) { + const tolerance = 1; + const round = (value) => Number(value.toFixed(1)); + if (!Array.isArray(longTasks)) return { supported: false, reason: 'no Long Task entries' }; + if (!Array.isArray(spans)) return { supported: false, reason: 'no resize phase spans' }; + const task = longTasks.reduce( + (longest, entry) => (!longest || entry.duration > longest.duration ? entry : longest), null, + ); + const empty = { + supported: true, longTaskMs: 0, geometryMoves: 0, + preflightMs: null, projectOtherMs: null, publishMs: null, labelsMs: null, + moveOtherMs: null, updateMs: null, otherMs: null, + frameRenderMs: null, forcedStyleLayoutMs: null, + }; + if (!task) return empty; + const taskEnd = task.startTime + task.duration; + const inTask = (span) => span.startMs >= task.startTime - tolerance + && span.endMs <= taskEnd + tolerance; + const contains = (outer, inner) => inner.startMs >= outer.startMs && inner.endMs <= outer.endMs; + const own = spans.filter(inTask); + const named = (name) => own.filter((span) => span.name === name); + const total = (list) => list.reduce((sum, span) => sum + span.endMs - span.startMs, 0); + const moves = named('move'); + const projects = named('project'); + const insideMove = (span) => moves.some((move) => contains(move, span)); + const preflights = named('preflight'); + const projectPreflights = preflights.filter((span) => projects.some((project) => contains(project, span))); + const moveMs = total(moves); + const projectMs = total(projects); + const publishMs = total(named('publish')); + const labelsMs = total(named('labels')); + // A Lit update is its own microtask after the move; one nested in a move + // (none today) is already part of that move's time. + const updateMs = total(named('update').filter((span) => !insideMove(span))); + // Preflight outside a projection (a commit-time validation) still belongs to + // the task, but is not double counted with a move that contains it. + const loosePreflightMs = total(preflights.filter((span) => !insideMove(span))); + const frame = Array.isArray(frames) + ? frames.find((entry) => entry.startTime <= task.startTime + tolerance + && entry.startTime + entry.duration >= taskEnd - tolerance) + : null; + const forced = frame + ? frame.scripts + .filter((script) => script.startTime >= task.startTime - tolerance + && script.startTime + script.duration <= taskEnd + tolerance) + .reduce((sum, script) => sum + (script.forcedStyleAndLayoutDuration || 0), 0) + : null; + return { + ...empty, + longTaskMs: task.duration, + geometryMoves: projects.length, + preflightMs: round(total(projectPreflights) + loosePreflightMs), + projectOtherMs: round(projectMs - total(projectPreflights)), + publishMs: round(publishMs), + labelsMs: round(labelsMs), + moveOtherMs: round(moveMs - projectMs - publishMs - labelsMs), + updateMs: round(updateMs), + otherMs: round(task.duration - moveMs - updateMs - loosePreflightMs), + frameRenderMs: frame + ? round(frame.renderStart > 0 ? frame.startTime + frame.duration - frame.renderStart : 0) + : null, + forcedStyleLayoutMs: forced == null ? null : round(forced), + }; +} diff --git a/test/performance-contract.test.mjs b/test/performance-contract.test.mjs index 933af22b..64f6dc52 100644 --- a/test/performance-contract.test.mjs +++ b/test/performance-contract.test.mjs @@ -163,6 +163,31 @@ test('large-house contract accepts only an explicit current or stable resize own () => assertCardContract({ ...methods, ...fields }, LARGE_HOUSE_CARD_CONTRACT), /missing private API: _resize\|_rszDrag/, ); + // #778: the attribution's nested member is optional (`_resize: {}` above + // passes) but, when present, typed like every other optional member. + assert.doesNotThrow(() => assertCardContract( + { ...methods, ...fields, _resize: { move: () => undefined } }, LARGE_HOUSE_CARD_CONTRACT, + )); + assert.throws( + () => assertCardContract( + { ...methods, ...fields, _resize: { move: true } }, LARGE_HOUSE_CARD_CONTRACT, + ), + /invalid private API types: _resize\.move:function/, + ); +}); + +test('resize Long Task attribution wraps the real ResizeController move shape (#778)', () => { + assert.deepEqual(LARGE_HOUSE_CARD_CONTRACT.optionalMethodsOf, { _resize: ['move'] }); + const controller = readFileSync(new URL('../src/resize-controller.ts', import.meta.url), 'utf8'); + const start = controller.indexOf(' move(input: {'); + const end = controller.indexOf('}): ResizeMoveOutcome', start); + assert.ok(start >= 0 && end > start, 'ResizeController.move(input) is present'); + const signature = controller.slice(start, end); + for (const callback of ['project', 'publish', 'measure']) + assert.match(signature, new RegExp(`\\n\\s+${callback}: \\(`), `move input declares ${callback}`); + const runner = readFileSync(new URL('../demo/benchmark_large_house.mjs', import.meta.url), 'utf8'); + assert.match(runner, /\['project', 'publish', 'measure'\]\.every\(/, + 'an unknown move shape must be reported, not timed as zero'); }); test('#770: 2.5D contract errors name the measured profile, not the historical one', () => { diff --git a/test/performance-resize-attribution.test.mjs b/test/performance-resize-attribution.test.mjs new file mode 100644 index 00000000..3ca7c3b2 --- /dev/null +++ b/test/performance-resize-attribution.test.mjs @@ -0,0 +1,101 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { attributeResizeLongTask } from '../demo/performance/resize-attribution.mjs'; + +// #778: the shape of a real CI sample (Validate 36910217188, attempt 1, +// sample 0: 135 ms, physical preflight 94.6 ms) on a page clock starting at 1000. +const span = (name, startMs, endMs) => ({ name, startMs, endMs }); +const moveSpans = (offset = 0) => [ + span('move', 1000.6 + offset, 1134.4 + offset), + span('project', 1000.8 + offset, 1100.0 + offset), + span('preflight', 1001.2 + offset, 1095.8 + offset), + span('publish', 1100.0 + offset, 1101.5 + offset), + span('labels', 1101.5 + offset, 1134.0 + offset), +]; +const frame = (duration, renderStart, scripts) => ({ startTime: 1000, duration, renderStart, scripts }); +const partsSum = (result) => ['preflightMs', 'projectOtherMs', 'publishMs', 'labelsMs', + 'moveOtherMs', 'updateMs', 'otherMs'].reduce((sum, key) => sum + result[key], 0); + +test('the judged move task is split into its phases and the parts add up (#778)', () => { + const result = attributeResizeLongTask({ + longTasks: [{ startTime: 1000, duration: 135 }], + spans: [ + // pointerdown's work finished before the window: not this task. + span('preflight', 990, 995), + ...moveSpans(), + // the cancel's settled render lands in a later, short task. + span('update', 1300, 1305), + ], + frames: [frame(141, 1135.2, [ + { startTime: 1000, duration: 135, forcedStyleAndLayoutDuration: 0 }, + { startTime: 1135.3, duration: 4.5, forcedStyleAndLayoutDuration: 0.8 }, + ])], + }); + assert.deepEqual(result, { + supported: true, + longTaskMs: 135, + geometryMoves: 1, + preflightMs: 94.6, + projectOtherMs: 4.6, + publishMs: 1.5, + labelsMs: 32.5, + moveOtherMs: 0.6, + updateMs: 0, + otherMs: 1.2, + frameRenderMs: 5.8, + forcedStyleLayoutMs: 0, + }); + assert.equal(Number(partsSum(result).toFixed(1)), result.longTaskMs); +}); + +test('a judged task without the move never borrows the move phases (#778)', () => { + // Negative witness: the move task is 120 ms, but a later 160 ms task (here + // a settled render) is the longest one, so it is the task the gate judged. + const result = attributeResizeLongTask({ + longTasks: [{ startTime: 1000, duration: 120 }, { startTime: 2000, duration: 160 }], + spans: [...moveSpans(), span('update', 2001, 2150)], + frames: null, + }); + assert.equal(result.longTaskMs, 160); + assert.equal(result.geometryMoves, 0); + assert.equal(result.preflightMs, 0); + assert.equal(result.labelsMs, 0); + assert.equal(result.updateMs, 149); + assert.equal(result.otherMs, 11); + assert.equal(result.frameRenderMs, null); + assert.equal(result.forcedStyleLayoutMs, null); +}); + +test('a stall outside the timed calls is reported as other, not as product work (#778)', () => { + // The 163 ms CI sample had a normal preflight and +25 ms elsewhere; this is + // how such a task reads when the extra time is outside every timed call. + const result = attributeResizeLongTask({ + longTasks: [{ startTime: 1000, duration: 160 }], + spans: moveSpans(25), + frames: [], + }); + assert.equal(result.geometryMoves, 1); + assert.equal(result.preflightMs, 94.6); + assert.equal(result.labelsMs, 32.5); + assert.equal(result.otherMs, 26.2); + assert.equal(result.frameRenderMs, null, 'no frame holds the task: unknown, not zero'); + assert.equal(Number(partsSum(result).toFixed(1)), result.longTaskMs); +}); + +test('missing browser support is reported instead of zero shares (#778)', () => { + assert.deepEqual( + attributeResizeLongTask({ longTasks: null, spans: moveSpans() }), + { supported: false, reason: 'no Long Task entries' }, + ); + assert.deepEqual( + attributeResizeLongTask({ longTasks: [], spans: null }), + { supported: false, reason: 'no resize phase spans' }, + ); + // A fast runner without any Long Task has nothing to split: the phases are + // unknown (null), not an invented zero split of a task that does not exist. + const quiet = attributeResizeLongTask({ longTasks: [], spans: moveSpans() }); + assert.equal(quiet.supported, true); + assert.equal(quiet.longTaskMs, 0); + assert.equal(quiet.preflightMs, null); + assert.equal(quiet.otherMs, null); +});