Dependency audit / audit (push) Canceled after 0s
CI / plan (push) Canceled after 0s
Gitoxide helper admission / macos-latest (push) Canceled after 0s
Gitoxide helper admission / ubuntu-latest (push) Canceled after 0s
Gitoxide helper admission / windows-latest (push) Canceled after 0s
Runtime Host peer admission / quality (push) Canceled after 0s
Windows recovery / windows_recovery (push) Canceled after 0s
CI / heavy (push) Canceled after 0s
CI / test (push) Canceled after 0s
241 lines
9.9 KiB
TypeScript
241 lines
9.9 KiB
TypeScript
/*
|
|
* Licensed to the Apache Software Foundation (ASF) under one
|
|
* or more contributor license agreements. See the NOTICE file
|
|
* distributed with this work for additional information
|
|
* regarding copyright ownership. The ASF licenses this file
|
|
* to you under the Apache License, Version 2.0 (the
|
|
* "License"); you may not use this file except in compliance
|
|
* with the License. You may obtain a copy of the License at
|
|
*
|
|
* http://www.apache.org/licenses/LICENSE-2.0
|
|
*
|
|
* Unless required by applicable law or agreed to in writing,
|
|
* software distributed under the License is distributed on an
|
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
* KIND, either express or implied. See the License for the
|
|
* specific language governing permissions and limitations
|
|
* under the License.
|
|
*/
|
|
|
|
import { expect, type Page } from '@playwright/test';
|
|
import {
|
|
ensureSidebarExpanded,
|
|
RAIL_RENDER_SESSION_COUNT,
|
|
test,
|
|
} from './fixtures.js';
|
|
|
|
/**
|
|
* Switching a session moves one row's selection. What it must not do is rewrite
|
|
* the rest of the rail.
|
|
*
|
|
* Deliberately a budget on the OUTCOME rather than an assertion about
|
|
* identities or `memo`. The rail's cost has had several independent causes —
|
|
* `setActiveId` changing identity every AppShell render, `Intl` formatters
|
|
* rebuilt per row, catalog refreshes replacing unchanged row objects — and each
|
|
* was invisible to the others. A DOM-write budget catches all of them and the
|
|
* ones not yet found, including anything that raises the number of commits a
|
|
* switch produces (#4109).
|
|
*
|
|
* The counter reads inline `style` writes on rail buttons because that is the
|
|
* dominant term: every Astryx button removes and re-adds its `anchor-name` per
|
|
* render, so one wasted rail render is two style writes per button plus the
|
|
* style recalculation they force.
|
|
*
|
|
* The assertion that carries the contract is `rowsTouched`, not the total.
|
|
* Attributing each write to its row makes the budget independent of how many
|
|
* rows the fixture seeds, and closes the hole a total-only budget leaves: a
|
|
* regression that re-renders the whole rail exactly ONCE stays under any total
|
|
* generous enough not to flake, but it cannot touch two rows. That is also the
|
|
* missing middle of the fix's own claim — identity is fixed so `memo` holds, and
|
|
* `memo` holding means untouched rows produce no DOM work at all.
|
|
*
|
|
* The last two assertions are the timing half, folded in from #4109's own rail
|
|
* budget rather than kept as a second spec: how much was rewritten does not say
|
|
* whether the user saw it. The selection must pass through exactly one row on
|
|
* its way to the clicked one, and the status badges — which belong to sessions
|
|
* whose state did not change — must not be torn down and rebuilt underneath it.
|
|
*
|
|
* `styleWrites > 0` is the counter's own liveness check. Every write counted
|
|
* here comes from an Astryx ref callback that is not wrapped in `useCallback`;
|
|
* if that upstream detail is ever memoised, both the healthy and the regressed
|
|
* reading collapse to zero and a one-sided budget would pass forever without
|
|
* ever failing again.
|
|
*/
|
|
const RAIL_ROWS_TOUCHED_BUDGET = 2;
|
|
/** The leaving row and the arriving row, two writes each, doubled for slack. */
|
|
const RAIL_STYLE_WRITE_BUDGET = 8;
|
|
|
|
interface RailCounters {
|
|
/** Cumulative since the last `resetRailCounters`; what the budgets read. */
|
|
styleWrites: number;
|
|
rowIds: string[];
|
|
rowRemounts: number;
|
|
/**
|
|
* The selected row after each batch of records, appended only when it moved.
|
|
* Counting `aria-current` writes instead would read a re-render that rewrites
|
|
* the same selection as the selection moving; this reads what the user sees.
|
|
*/
|
|
selectedRowIds: (string | null)[];
|
|
/**
|
|
* Status badges added or removed. They belong to sessions whose state did not
|
|
* change, so rebuilding them is the visible half of a rail re-render: the
|
|
* badges flash. `rowRemounts` does not cover it — the row survives.
|
|
*/
|
|
statusNodeChanges: number;
|
|
/** Drained by every quiet poll, so it reports only the latest interval. */
|
|
delta: number;
|
|
}
|
|
|
|
type RailWindow = Window & { __railCounters: RailCounters };
|
|
|
|
/**
|
|
* Waits until the rail has been silent for ~300ms.
|
|
*
|
|
* A fixed `waitForTimeout` would be the only thing standing between a slow
|
|
* machine and a red run: `toHaveCount` proves the rows mounted, not that the
|
|
* commits behind them are done, and one late catalog refresh writes more than
|
|
* the whole budget. `retries` is 0 in `playwright.config.ts`, so that failure
|
|
* would land on an unrelated pull request.
|
|
*/
|
|
async function waitForRailQuiet(page: Page): Promise<void> {
|
|
let quietPolls = 0;
|
|
await expect
|
|
.poll(
|
|
async () => {
|
|
const delta = await page.evaluate(() => {
|
|
const counters = (window as unknown as RailWindow).__railCounters;
|
|
const seen = counters.delta;
|
|
counters.delta = 0;
|
|
return seen;
|
|
});
|
|
quietPolls = delta === 0 ? quietPolls + 1 : 0;
|
|
return quietPolls;
|
|
},
|
|
{ timeout: 15_000, intervals: [100] },
|
|
)
|
|
.toBeGreaterThanOrEqual(3);
|
|
}
|
|
|
|
test('switching sessions does not rewrite the whole Session rail', async ({
|
|
railRenderWindow: page,
|
|
}) => {
|
|
await ensureSidebarExpanded(page);
|
|
|
|
const rows = page.locator('.maka-session-row');
|
|
await expect(rows).toHaveCount(RAIL_RENDER_SESSION_COUNT);
|
|
|
|
const target = page.locator('.maka-session-row button.astryx-side-nav-item', {
|
|
hasText: 'Rail row 3',
|
|
});
|
|
const selected = page.locator('.maka-session-row button.astryx-side-nav-item.selected');
|
|
await expect(target).toBeVisible();
|
|
|
|
await page.evaluate(() => {
|
|
const selectedRowId = (): string | null =>
|
|
document
|
|
.querySelector('.maka-session-row button.astryx-side-nav-item.selected')
|
|
?.closest('.maka-session-row')
|
|
?.getAttribute('data-session-id') ?? null;
|
|
|
|
const counters = {
|
|
styleWrites: 0,
|
|
rowIds: [] as string[],
|
|
rowRemounts: 0,
|
|
selectedRowIds: [selectedRowId()] as (string | null)[],
|
|
statusNodeChanges: 0,
|
|
delta: 0,
|
|
};
|
|
(window as unknown as { __railCounters: typeof counters }).__railCounters = counters;
|
|
const observer = new MutationObserver((records) => {
|
|
for (const record of records) {
|
|
if (record.type === 'childList') {
|
|
for (const node of record.addedNodes) {
|
|
const element = node as Element;
|
|
if (element.nodeType !== 1) continue;
|
|
// A row that unmounts and remounts writes its `anchor-name` once
|
|
// on the way in, from a ref callback that runs AFTER insertion —
|
|
// so an attribute-only counter reads a whole rail remount as
|
|
// cheaper than a rail re-render. Count the remounts directly.
|
|
if (element.classList?.contains('maka-session-row')) counters.rowRemounts += 1;
|
|
}
|
|
for (const node of [...record.addedNodes, ...record.removedNodes]) {
|
|
const element = node as Element;
|
|
if (element.nodeType !== 1) continue;
|
|
if (
|
|
element.matches?.('[data-session-status]') ||
|
|
element.querySelector?.('[data-session-status]')
|
|
) {
|
|
counters.statusNodeChanges += 1;
|
|
}
|
|
}
|
|
continue;
|
|
}
|
|
const row = (record.target as Element).closest?.('.maka-session-row');
|
|
if (!row) continue;
|
|
counters.styleWrites += 1;
|
|
counters.delta += 1;
|
|
const rowId = row.getAttribute('data-session-id');
|
|
if (rowId && !counters.rowIds.includes(rowId)) counters.rowIds.push(rowId);
|
|
}
|
|
const selected = selectedRowId();
|
|
if (selected !== counters.selectedRowIds.at(-1)) counters.selectedRowIds.push(selected);
|
|
});
|
|
observer.observe(document.body, {
|
|
subtree: true,
|
|
childList: true,
|
|
attributes: true,
|
|
attributeFilter: ['style'],
|
|
});
|
|
(window as unknown as { __railObserver: MutationObserver }).__railObserver = observer;
|
|
});
|
|
|
|
// Settle first: the budget is about a switch, not about arriving.
|
|
await waitForRailQuiet(page);
|
|
await page.evaluate(() => {
|
|
const counters = (window as unknown as RailWindow).__railCounters;
|
|
counters.styleWrites = 0;
|
|
counters.rowIds = [];
|
|
counters.rowRemounts = 0;
|
|
counters.selectedRowIds = counters.selectedRowIds.slice(-1);
|
|
counters.statusNodeChanges = 0;
|
|
counters.delta = 0;
|
|
});
|
|
|
|
const targetId = await page
|
|
.locator('.maka-session-row', { hasText: 'Rail row 3' })
|
|
.getAttribute('data-session-id');
|
|
expect(targetId).toBeTruthy();
|
|
|
|
await target.click();
|
|
await expect(selected).toHaveText(/Rail row 3/);
|
|
// Let the post-switch commit cascade finish before reading the counters.
|
|
await waitForRailQuiet(page);
|
|
|
|
const counted = await page.evaluate(() => {
|
|
const scope = window as unknown as RailWindow & { __railObserver: MutationObserver };
|
|
scope.__railObserver.disconnect();
|
|
const { styleWrites, rowIds, rowRemounts, selectedRowIds, statusNodeChanges } =
|
|
scope.__railCounters;
|
|
return { styleWrites, rowIds, rowRemounts, selectedRowIds, statusNodeChanges };
|
|
});
|
|
|
|
expect(
|
|
counted.rowIds.length,
|
|
`rail rows touched by one session switch, of ${RAIL_RENDER_SESSION_COUNT} (${counted.rowIds.join(', ')})`,
|
|
).toBeLessThanOrEqual(RAIL_ROWS_TOUCHED_BUDGET);
|
|
expect(counted.rowRemounts, 'rail rows remounted by one session switch').toBe(0);
|
|
expect(counted.styleWrites, 'the style-write counter never fired').toBeGreaterThan(0);
|
|
expect(counted.styleWrites, 'rail inline-style writes for one session switch').toBeLessThanOrEqual(
|
|
RAIL_STYLE_WRITE_BUDGET,
|
|
);
|
|
|
|
// The timing half. A budget says how much was rewritten, not whether the user
|
|
// saw it happen: a switch that lands the selection on a third row and takes it
|
|
// back stays well inside every count above (#4109).
|
|
expect(
|
|
counted.selectedRowIds.slice(1),
|
|
'rows the selection passed through during one switch',
|
|
).toEqual([targetId]);
|
|
expect(counted.statusNodeChanges, 'status badges rebuilt by one session switch').toBe(0);
|
|
});
|