e2e: fit camera over the crossing leg before querying the planes source; cleanup always runs (run-176)
CI / build + unit + e2e (pull_request) Failing after 21m24s

This commit is contained in:
2026-09-22 22:58:03 +00:00
parent c06e038130
commit baa4b0fa7c
3 changed files with 109 additions and 44 deletions
+94 -44
View File
@@ -120,7 +120,8 @@ async function loadAndFly(page: Page, fromIcao: string, toIcao: string): Promise
return pkg;
}
// All features of a GeoJSON source (viewport-independent), [] until the map/source exists.
// Features in LOADED tiles only — MapLibre loads GeoJSON per viewport tile, so the camera must be over
// the area of interest first ([] until then). Same trap as run-176: Europe view + Pacific plane = [].
function sourceFeatures(page: Page, sourceId: string) {
return page.evaluate((srcId: string) => {
const m = (window as any).__flightMap;
@@ -413,6 +414,42 @@ function starterMarker(page: Page): Promise<{ lon: number; flying: boolean } | n
});
}
// Jump the camera to a box around the given airports. Copied from e2e/hover.spec.ts — that version
// unwraps crossing pairs (ISS-120) so a ±180-straddling pair fits the Pacific, not the Atlantic.
async function fitToAirports(page: Page, icaos: string[]): Promise<void> {
const fc = (await (await page.request.get('/api/airports')).json()) as {
features: Array<{ properties: { icao: string; lat: number; lon: number } }>;
};
const points = icaos.map((icao) => {
const f = fc.features.find((x) => x.properties.icao === icao);
if (!f) throw new Error(`airport ${icao} not found in /api/airports`);
return [f.properties.lon, f.properties.lat] as [number, number];
});
for (let i = 1; i < points.length; i++) {
let lon = points[i]![0];
const prev = points[i - 1]![0];
while (lon - prev > 180) lon -= 360;
while (prev - lon > 180) lon += 360;
points[i]![0] = lon;
}
await page.evaluate((pts: [number, number][]) => {
const m = (window as any).__flightMap;
if (!m) throw new Error('map not ready');
const lons = pts.map((p) => p[0]);
const lats = pts.map((p) => p[1]);
// duration 0 → instant camera jump, no animation to wait out.
m.fitBounds(
[
[Math.min(...lons), Math.min(...lats)],
[Math.max(...lons), Math.max(...lats)],
],
{ padding: 40, duration: 0 },
);
}, points);
// Give the renderer a beat after the camera jump before asserting on rendered features.
await page.waitForTimeout(300);
}
test('plane marker stays continuous across the antimeridian (no 360° teleport)', async ({ page }) => {
test.setTimeout(900_000); // relocation chain (~3 min at 1000×) + crossing leg + return chain + margin
@@ -428,52 +465,65 @@ test('plane marker stays continuous across the antimeridian (no 360° teleport)'
await setScale(page, SCALE);
await expect.poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.status ?? '', { timeout: 240_000 }).toBe('available');
}
let loc = (await getState(page)).planes.find((p) => p.id === 'plane-starter')!.locationIcao;
if (loc !== ORIGIN) {
for (const next of await chainTo(page, loc, ORIGIN)) {
await setFuelFull(page, 'plane-starter'); // full tank → auto-refuel tops up 0 L and costs nothing
expect((await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: next } })).status()).toBe(200);
await setScale(page, SCALE);
await expect.poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.locationIcao ?? '', { timeout: 240_000 }).toBe(next);
// Relocate + crossing leg. The finally ALWAYS runs — even when a poll below fails, the plane must end
// grounded at HUB so no later spec's fly-home gets a 409 (the run-176 pollution cascade).
try {
let loc = (await getState(page)).planes.find((p) => p.id === 'plane-starter')!.locationIcao;
if (loc !== ORIGIN) {
for (const next of await chainTo(page, loc, ORIGIN)) {
await setFuelFull(page, 'plane-starter'); // full tank → auto-refuel tops up 0 L and costs nothing
expect((await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: next } })).status()).toBe(200);
await setScale(page, SCALE);
await expect.poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.locationIcao ?? '', { timeout: 240_000 }).toBe(next);
}
}
loc = ORIGIN;
}
// The crossing package + dispatch.
await setFuelFull(page, 'plane-starter');
const pkg = await createPackage(page, ORIGIN, CROSS_DEST);
expect((await page.request.post(`/api/packages/${pkg.id}/load`, { data: { planeId: 'plane-starter' } })).status()).toBe(200);
expect((await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: CROSS_DEST } })).status()).toBe(200);
await setScale(page, SCALE);
// Wait until the map's planes source shows it flying (the source updates from ~1 s state polls).
await expect.poll(async () => (await starterMarker(page))?.flying ?? false, { timeout: 15_000, message: 'waiting for the flying plane feature in the planes source' }).toBe(true);
// Sample the marker's longitude while it crosses. Stop once the SERVER says landed; record only
// FLYING frames — a grounded frame snaps back to its raw airport coordinate (−175.1), the same screen
// point but a 360° jump in raw data, and could land between this loop's server read and source read.
const lons: number[] = [];
for (;;) {
const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter')!;
if (p.status !== 'flying') break;
const marker = await starterMarker(page);
if (marker?.flying) lons.push(marker.lon);
await page.waitForTimeout(500);
}
expect(lons.length).toBeGreaterThan(3); // a real flight window, not a degenerate one
for (let i = 1; i < lons.length; i++) {
expect(Math.abs(lons[i]! - lons[i - 1]!), `marker jumped between samples ${i - 1} and ${i}`).toBeLessThan(180); // the ISS-124 bug
}
expect(lons.some((lon) => lon > 180)).toBe(true); // crossed onto the east world copy (NZAA→NFTF is eastward)
expect(Math.max(...lons) - Math.min(...lons)).toBeGreaterThan(4); // traversed the crossing region, not a static window
// Cleanup: chain back home so the other specs start from the canonical state. The package was delivered
// on arrival (generous deadline).
for (const next of await chainTo(page, CROSS_DEST, HUB)) {
// The crossing package + dispatch.
await setFuelFull(page, 'plane-starter');
expect((await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: next } })).status()).toBe(200);
const pkg = await createPackage(page, ORIGIN, CROSS_DEST);
expect((await page.request.post(`/api/packages/${pkg.id}/load`, { data: { planeId: 'plane-starter' } })).status()).toBe(200);
const fly = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: CROSS_DEST } });
if (fly.status() !== 200) throw new Error(`dispatch POST /api/planes/plane-starter/fly → ${fly.status()} (expected 200)`);
await setScale(page, SCALE);
await expect.poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.locationIcao ?? '', { timeout: 240_000 }).toBe(next);
// querySourceFeatures only sees LOADED tiles — the default view is over Europe and this leg is in the
// Pacific: fit the camera over both endpoints first or the plane feature never appears in queries.
await fitToAirports(page, [ORIGIN, CROSS_DEST]);
// Wait until the map's planes source shows it flying (the source updates from ~1 s state polls).
await expect.poll(async () => (await starterMarker(page))?.flying ?? false, { timeout: 15_000, message: 'waiting for the flying plane feature in the planes source' }).toBe(true);
// Sample the marker's longitude while it crosses. Stop once the SERVER says landed; record only
// FLYING frames — a grounded frame snaps back to the raw airport coordinate (−175.1), the same screen
// point but a 360° jump in raw data, and could land between this loop's server read and source read.
const lons: number[] = [];
for (;;) {
const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter')!;
if (p.status !== 'flying') break;
const marker = await starterMarker(page);
if (marker?.flying) lons.push(marker.lon);
await page.waitForTimeout(500);
}
expect(lons.length).toBeGreaterThan(3); // a real flight window, not a degenerate one
for (let i = 1; i < lons.length; i++) {
expect(Math.abs(lons[i]! - lons[i - 1]!), `marker jumped between samples ${i - 1} and ${i}`).toBeLessThan(180); // the ISS-124 bug
}
expect(lons.some((lon) => lon > 180)).toBe(true); // crossed onto the east world copy (NZAA→NFTF is eastward)
expect(Math.max(...lons) - Math.min(...lons)).toBeGreaterThan(4); // traversed the crossing region, not a static window
} finally {
// Cleanup: let any in-flight leg land, then chain back home so the other specs start from the canonical
// state. The package was delivered on arrival (generous deadline).
await setScale(page, SCALE);
await expect.poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.status ?? '', { timeout: 240_000 }).toBe('available');
const endLoc = (await getState(page)).planes.find((p) => p.id === 'plane-starter')!.locationIcao;
if (endLoc !== HUB) {
for (const next of await chainTo(page, endLoc, HUB)) {
await setFuelFull(page, 'plane-starter');
expect((await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: next } })).status()).toBe(200);
await setScale(page, SCALE);
await expect.poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.locationIcao ?? '', { timeout: 240_000 }).toBe(next);
}
}
}
});