mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-07 15:09:30 +00:00
test(perf): attribute the resize Long Task of the interaction profile (#778)
Validate on #762 failed once on longTask.editorSeries.maxSingleMs (135/129/163 against 150 ms) and passed on the re-run (127/132/132) without a change. The report carried only the task's maximum and the physical preflight time, so nobody could tell whether the extra time was the preflight, the rest of the resize step, or something else on the runner. The investigation found no measurement defect. The resize part's single Long Task is the task of the one accepted pointer move: ResizeController.move with the live physical preflight inside project, publish and the live labels. No Lit update, forced layout or paint is in it (Long Animation Frames and a Chrome trace show the live paint and the frame's layout in the next rendering step); pointerdown stays in an earlier task. Across 202 CI samples (14 Full Performance runs and both Validate attempts) the task minus the preflight is 20-47 ms with a within-report sigma of at most 2.2 ms; the failed sample had a normal preflight and +25 ms elsewhere, plus extra Long Tasks in its load and space-switch windows: one slow sample of the whole page on top of a level that sits 3-25 % under the ceiling. Each interaction row now reports interactionDiagnostics.resizeLongTask: the judged task split into preflight, projection, publish, labels, the rest of the move, Lit updates and other time, plus the following frame's render and any forced style/layout from Long Animation Frames. The split is computed by a self-contained function from the entries of the very window the gate judges, and the wrappers and observer are installed before the runner's yield, so nothing new runs inside the measured task. A base without the ResizeController.move callback shape reports supported: false with a reason instead of zero shares; the contract declares _resize.move as an optional method. The runner prints one line per sample for the job log. No budget, window or product code changes; the README records the series, the method and how to read the next failure. Issue: #778 User-Visible: no Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user