mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 03:14:00 +00:00
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:
1 parent
77575b9dc6
commit
1a74e56bc0
1 file changed
+143
-9
@@ -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',
|
||||||
|
|||||||
Reference in new issue
Block a user