test: isolate Cockpit readiness and verify keyboard focus in the browser

Use a fresh page for first-fetch assertions and wait for rendered camera state instead of fixed timing. Check connected focus retention during row reorder, departed-contact continuation, and attribution keyboard ownership in Cockpit.
This commit is contained in:
Sameh Khamis committed 2026-09-10 23:48:24 -07:00
1 parent 77575b9dc6
commit 1a74e56bc0
1 file changed
+143 -9
+143 -9
View File
@@ -60,8 +60,22 @@ const check = (name, passed, detail) => {
try { try {
await page.setViewport({ width: 1440, height: 900, deviceScaleFactor: 1 }); await page.setViewport({ width: 1440, height: 900, deviceScaleFactor: 1 });
await page.setRequestInterception(true); await page.setRequestInterception(true);
page.on('request', (request) => { const routeQaRequest = (request) => {
const url = new URL(request.url()); const url = new URL(request.url());
// This harness verifies UI/lifecycle behavior, not DEM accuracy. Keep an
// unrelated upstream terrain outage out of the rendered interaction gate.
if (url.origin === new URL(appUrl).origin && url.pathname === '/api/terrain/heights') {
const points = (url.searchParams.get('points') || '').split(';').filter(Boolean);
request.respond({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ results: points.map((point) => {
const [lon, lat] = point.split(',').map(Number);
return { lon, lat, elevation: 0, geoid: 0, ellipsoid: 0 };
}) }),
});
return;
}
if (url.origin === new URL(appUrl).origin && url.pathname === '/api/ais-live') { if (url.origin === new URL(appUrl).origin && url.pathname === '/api/ais-live') {
request.respond({ request.respond({
status: 200, status: 200,
@@ -117,7 +131,8 @@ try {
return; return;
} }
request.continue(); request.continue();
}); };
page.on('request', routeQaRequest);
await page.goto(appUrl, { waitUntil: 'domcontentloaded', timeout: 60_000 }); await page.goto(appUrl, { waitUntil: 'domcontentloaded', timeout: 60_000 });
await page.waitForFunction(() => window.__godsEyeView?.styleManager, { timeout: 60_000 }); await page.waitForFunction(() => window.__godsEyeView?.styleManager, { timeout: 60_000 });
await page.waitForFunction( await page.waitForFunction(
@@ -774,7 +789,46 @@ try {
// was created to fix. // was created to fix.
// Holding the network reproduces the field exactly and the panel tracks it, // Holding the network reproduces the field exactly and the panel tracks it,
// which is what gives these assertions teeth. // which is what gives these assertions teeth.
const deferredInstallationReadiness = await page.evaluate(async () => { // Earlier scenarios have already loaded installations. Use a fresh page so
// a legitimate retained snapshot cannot satisfy or invalidate a FIRST-fetch
// assertion when camera motion supersedes the manager's initial update.
const readinessPage = await browser.newPage();
let deferredInstallationReadiness;
try {
await readinessPage.setViewport({ width: 1440, height: 900 });
readinessPage.on('pageerror', (error) => consoleErrors.push(error.message));
await readinessPage.setRequestInterception(true);
readinessPage.on('request', (request) => {
const url = new URL(request.url());
if (url.origin === new URL(appUrl).origin && url.pathname === '/api/opensky') {
const now = Math.floor(Date.now() / 1000);
request.respond({
status: 200, contentType: 'application/json',
body: JSON.stringify({ time: now, states: [
['aaa051', 'QA051', 'Synthetic', now, now, -97.7431, 30.2672,
9000, false, 230, 90, 0, null, 9000, null, false, 0],
] }),
});
return;
}
routeQaRequest(request);
});
const readinessUrl = new URL(appUrl);
readinessUrl.searchParams.set('welcome', '0');
await readinessPage.goto(readinessUrl.href, { waitUntil: 'domcontentloaded' });
await readinessPage.waitForFunction(() => window.__godsEyeView?.dataManager
&& document.getElementById('loading-screen')?.classList.contains('hidden'), { timeout: 60000 });
await readinessPage.evaluate(async () => {
const manager = window.__godsEyeView.dataManager;
await manager.setEnabled('flights', true, { origin: 'user' });
if (!manager.layers.get('flights').module.trackById('aaa051')) {
throw new Error('First-fetch fixture aircraft was not tracked');
}
if (manager.layers.get('military-installations').module.getStats().lastUpdate != null) {
throw new Error('First-fetch fixture already has an installation snapshot');
}
});
deferredInstallationReadiness = await readinessPage.evaluate(async () => {
const { styleManager, dataManager } = window.__godsEyeView; const { styleManager, dataManager } = window.__godsEyeView;
const entry = dataManager.layers.get('military-installations'); const entry = dataManager.layers.get('military-installations');
if (!entry?.module) return { exercised: false, reason: 'military-installations missing' }; if (!entry?.module) return { exercised: false, reason: 'military-installations missing' };
@@ -914,6 +968,10 @@ try {
} }
} }
}); });
} finally {
await readinessPage.close();
await page.bringToFront();
}
// The panel has two truthful ways to say "nothing has answered yet" — the // The panel has two truthful ways to say "nothing has answered yet" — the
// cohort count renders `?`, and an UNKNOWN relationship renders as that word. // cohort count renders `?`, and an UNKNOWN relationship renders as that word.
// Which one is on screen depends on which panel surface is mounted, and the // Which one is on screen depends on which panel surface is mounted, and the
@@ -922,6 +980,11 @@ try {
// assert numeric-vs-not, which is exactly the field report. // assert numeric-vs-not, which is exactly the field report.
const readsUnknown = (value) => typeof value === 'string' && value.length > 0 && !/^\d/.test(value); const readsUnknown = (value) => typeof value === 'string' && value.length > 0 && !/^\d/.test(value);
const readsNumber = (value) => typeof value === 'string' && /^\d+$/.test(value); const readsNumber = (value) => typeof value === 'string' && /^\d+$/.test(value);
// Loading has explicit copy since the installations reliability update.
// Keep the numeric/count gates below: wording alone never proves readiness.
const readsPendingReason = (value) => (
/unavailable|stale|Mapped sites not loaded|Fetching mapped sites…/i.test(String(value))
);
check( check(
'mapped installations load behind Contacts without a false all-clear or a locked Cockpit', 'mapped installations load behind Contacts without a false all-clear or a locked Cockpit',
deferredInstallationReadiness.exercised deferredInstallationReadiness.exercised
@@ -930,7 +993,7 @@ try {
&& deferredInstallationReadiness.contacts.value === true && deferredInstallationReadiness.contacts.value === true
&& deferredInstallationReadiness.pendingLifecycle?.lifecycleState === 'enabling' && deferredInstallationReadiness.pendingLifecycle?.lifecycleState === 'enabling'
&& readsUnknown(deferredInstallationReadiness.pendingCount) && readsUnknown(deferredInstallationReadiness.pendingCount)
&& /unavailable|stale/i.test(String(deferredInstallationReadiness.pendingReason)) && readsPendingReason(deferredInstallationReadiness.pendingReason)
&& deferredInstallationReadiness.cockpitAvailableAfterActivation && deferredInstallationReadiness.cockpitAvailableAfterActivation
&& deferredInstallationReadiness.installed?.settled, && deferredInstallationReadiness.installed?.settled,
JSON.stringify(deferredInstallationReadiness), JSON.stringify(deferredInstallationReadiness),
@@ -943,10 +1006,11 @@ try {
&& deferredInstallationReadiness.fetchingCounts.length > 0 && deferredInstallationReadiness.fetchingCounts.length > 0
&& deferredInstallationReadiness.fetchingCounts.every(readsUnknown) && deferredInstallationReadiness.fetchingCounts.every(readsUnknown)
&& Array.isArray(deferredInstallationReadiness.fetchingReasons) && Array.isArray(deferredInstallationReadiness.fetchingReasons)
&& deferredInstallationReadiness.fetchingReasons.every((r) => /unavailable|stale/i.test(String(r))), && deferredInstallationReadiness.fetchingReasons.every(readsPendingReason),
JSON.stringify({ JSON.stringify({
settledLifecycle: deferredInstallationReadiness.settledLifecycle, settledLifecycle: deferredInstallationReadiness.settledLifecycle,
fetchingCounts: deferredInstallationReadiness.fetchingCounts, fetchingCounts: deferredInstallationReadiness.fetchingCounts,
fetchingReasons: deferredInstallationReadiness.fetchingReasons,
}), }),
); );
check( check(
@@ -954,7 +1018,7 @@ try {
deferredInstallationReadiness.exercised deferredInstallationReadiness.exercised
&& deferredInstallationReadiness.answered?.settled && deferredInstallationReadiness.answered?.settled
&& readsNumber(deferredInstallationReadiness.settledCount) && readsNumber(deferredInstallationReadiness.settledCount)
&& !/unavailable|stale/i.test(String(deferredInstallationReadiness.settledReason)), && !readsPendingReason(deferredInstallationReadiness.settledReason),
JSON.stringify({ JSON.stringify({
answered: deferredInstallationReadiness.answered, answered: deferredInstallationReadiness.answered,
settledCount: deferredInstallationReadiness.settledCount, settledCount: deferredInstallationReadiness.settledCount,
@@ -1006,7 +1070,12 @@ try {
); );
const zoomedRange = cameraRange(); const zoomedRange = cameraRange();
const refocused = awareness?.focusCurrent?.() === true; const refocused = awareness?.focusCurrent?.() === true;
await new Promise((resolve) => setTimeout(resolve, 180)); // The follow frame commits in Cesium preUpdate. A fixed 180ms delay can
// sample before that frame on a busy software renderer.
const frameDeadline = performance.now() + 5000;
while (cameraRange() >= 50_000 && performance.now() < frameDeadline) {
await new Promise((resolve) => setTimeout(resolve, 25));
}
const focusedRange = cameraRange(); const focusedRange = cameraRange();
const after = awareness?.getContextSnapshot?.()?.subject || null; const after = awareness?.getContextSnapshot?.()?.subject || null;
return { return {
@@ -1345,12 +1414,25 @@ try {
const { styleManager, dataManager, viewer } = window.__godsEyeView; const { styleManager, dataManager, viewer } = window.__godsEyeView;
const awareness = dataManager.layers.get('military-awareness')?.module; const awareness = dataManager.layers.get('military-awareness')?.module;
const entity = viewer.trackedEntity || styleManager.cockpitView.trackedEntity; const entity = viewer.trackedEntity || styleManager.cockpitView.trackedEntity;
const nextFrame = () => new Promise((resolve, reject) => {
const timer = setTimeout(() => {
remove();
reject(new Error('Cockpit camera ownership check did not render a frame'));
}, 5000);
const remove = viewer.scene.postRender.addEventListener(() => {
remove();
clearTimeout(timer);
resolve();
});
});
// Let revoked listeners retire before comparing ownership across exit.
await nextFrame();
const listenersBeforeExit = viewer.scene.preUpdate.numberOfListeners; const listenersBeforeExit = viewer.scene.preUpdate.numberOfListeners;
const exited = styleManager.cockpitView.exit() === true; const exited = styleManager.cockpitView.exit() === true;
await new Promise((resolve) => setTimeout(resolve, 80)); await nextFrame();
const listenersAfterExit = viewer.scene.preUpdate.numberOfListeners; const listenersAfterExit = viewer.scene.preUpdate.numberOfListeners;
const refocused = awareness?.focusCurrent?.() === true; const refocused = awareness?.focusCurrent?.() === true;
await new Promise((resolve) => setTimeout(resolve, 80)); await nextFrame();
return { return {
exited, exited,
refocused, refocused,
@@ -1913,6 +1995,58 @@ try {
&& contextTransition.label === 'Collapse Contact panel', && contextTransition.label === 'Collapse Contact panel',
JSON.stringify(contextTransition), JSON.stringify(contextTransition),
); );
// Use the real connected DOM to catch focus loss caused by moving live rows.
const signalFocus = await page.evaluate(() => {
const cockpit = window.__godsEyeView.styleManager.cockpitView;
const originalItems = cockpit.signalItems;
const wasCollapsed = cockpit.signalCollapsed;
cockpit.setSignalCollapsed(false);
const item = (id) => ({ key: `qa-${id}`, tone: 'info', timestamp: Date.now(),
title: `QA ${id}`, detail: 'Keyboard refresh fixture', target: { layerId: 'flights', id } });
try {
cockpit.signalItems = [item('focus-a'), item('focus-b')];
cockpit.renderCockpitSignals();
const button = cockpit.signalList.querySelector('[data-signal-id="focus-a"]');
button.focus();
const acquired = document.activeElement === button;
cockpit.signalItems = [item('focus-c'), item('focus-b'), item('focus-a')];
cockpit.renderCockpitSignals();
const retained = document.activeElement === button && button.isConnected;
const order = [...cockpit.signalList.querySelectorAll('[data-signal-id]')].map((node) => node.dataset.signalId);
cockpit.signalItems = [item('focus-b')];
cockpit.renderCockpitSignals();
const continuation = cockpit.briefTabs[cockpit.briefPageIndex] || cockpit.signalToggle;
const continued = document.activeElement === continuation;
cockpit.signalItems = [item('focus-a'), item('focus-b')];
cockpit.renderCockpitSignals();
return { acquired, retained, order, continued, notReclaimed: document.activeElement === continuation };
} finally {
cockpit.signalItems = originalItems;
cockpit.renderCockpitSignals();
cockpit.setSignalCollapsed(wasCollapsed);
}
});
check('Cockpit live reorder retains the same connected focus owner',
signalFocus.acquired && signalFocus.retained
&& JSON.stringify(signalFocus.order) === JSON.stringify(['focus-c', 'focus-b', 'focus-a']),
JSON.stringify(signalFocus));
check('a departed Cockpit contact continues at the footer without later reclaiming focus',
signalFocus.continued && signalFocus.notReclaimed, JSON.stringify(signalFocus));
// Real keyboard events also verify the nested lightbox does not exit Cockpit.
await page.focus('#cesium-credits .cesium-credit-expand-link');
await page.keyboard.press('Enter');
check('keyboard attribution opening focuses Close inside Cockpit', await page.evaluate(() => (
document.activeElement?.classList.contains('cesium-credit-lightbox-close')
&& document.querySelector('.cesium-credit-expand-link').getAttribute('aria-expanded') === 'true'
)));
await page.screenshot({ path: path.join(shotsDir, 'keyboard-attribution.png') });
await page.keyboard.press('Escape');
check('attribution Escape restores focus and keeps Cockpit active', await page.evaluate(() => (
document.activeElement?.classList.contains('cesium-credit-expand-link')
&& document.activeElement.getAttribute('aria-expanded') === 'false'
&& window.__godsEyeView.styleManager.cockpitView.active
)));
await page.screenshot({ path: path.join(shotsDir, 'restored-desktop.png') }); await page.screenshot({ path: path.join(shotsDir, 'restored-desktop.png') });
check( check(
'restored screenshot remains in a real Cockpit session', 'restored screenshot remains in a real Cockpit session',