Files
world/e2e/globe-render.spec.ts
hanzo-dev d4423c5bb1 fix(world/globe): restore globe-render e2e to green + expose __deckMap when functional
The globe-render acceptance suite went red as the flagship Cloud view evolved
(cloud now opens on the native 3D globe, controls collapsed into dropdowns); none
of it was a globe-render bug — the sphere renders and occludes correctly — the
harness just no longer matched the app. Restore it to 6/6:

- e2e/globe-render: pin the 2D->3D toggle path with ?mode=2d. Cloud defaults to the
  immersive 3D globe, which parks the mapbox map and mounts .deckgl-map-wrapper
  hidden, so the old `toBeVisible` precondition could never pass. Enter 3D through
  the proj-btn's REAL click handler (the immersive dock floats over the full-view
  globe canvas, so a headless synthesized mouse click's hit-test lands on the
  canvas, not the collapsed-dropdown button). This spec verifies GLOBE render
  (occlusion/terrain/parity/live dots); dock pointer reachability is out of scope.

- DeckGLMap: expose __deckMap/__mapboxMap as soon as the instance is FUNCTIONAL
  (right after initBasemap creates this.mapboxMap) instead of on the async mapbox
  'load'. When Cloud opens straight into the parked-mapbox native globe, 'load' can
  trail the globe by seconds, so the e2e read an undefined __deckMap right after
  go3D. buildLayers()/asGlobeSource()/setOcclusionCenter()/occludeFarSide() are all
  live at construction; __deckOverlay stays exposed in initDeck where it is built.

- vite: optimizeDeps.include the deck.gl / luma.gl / mapbox graph so a cold dev/e2e
  server pre-bundles @luma.gl/webgl's device adapter up front and never re-optimizes
  mid-load — that reload otherwise races GlobeNative's `new Deck()` and the 3D globe
  never initialises. Prod is unaffected (Rollup build, no dep optimizer).

Verified: e2e/globe-render.spec.ts 6/6 green on an unminified dev server; the dot
globe renders (4726 land dots), traffic dots occlude (back-hemisphere alpha 0),
count badges cull, terrain drapes single-context, live dots update.
2026-07-21 16:05:16 -07:00

224 lines
13 KiB
TypeScript

import { expect, test } from '@playwright/test';
// Globe render-fix acceptance suite (world.hanzo.ai Hanzo-Cloud view).
//
// Guards the three "spazz" bugs the CTO reported and the 2D/3D parity + live-analytics
// contract:
// 1. Overlays must sit ON the globe surface with correct occlusion — a back-hemisphere
// dot/badge is HIDDEN, a front one is drawn (no floating layers above the sphere).
// 2. Terrain drapes on the globe in a single WebGL context (no second canvas, no
// striping regression — depth-write disabled on the coplanar imagery tiles).
// 3. Every cloud data layer mounts in BOTH 2D (mercator) and 3D (globe) — parity.
// Plus: the REAL live request-geo dots appear and update.
//
// Data is mocked at the same-origin /v1/world/cloud/* contracts so the run is
// deterministic and offline (production serves the real, live payloads).
type DeckLayer = { id: string; props: Record<string, unknown> };
type Rgba = [number, number, number, number];
const cloudMap = {
// Two request-origin points on OPPOSITE hemispheres so occlusion is testable:
// FRONT (lon 0) faces a camera centred at lon 0; BACK (lon 180) is behind the globe.
trafficGlobe: {
updatedAt: '2026-07-18T12:00:00Z',
live: true,
window: { minutes: 60, since: '2026-07-18T11:00:00Z', until: '2026-07-18T12:00:00Z' },
points: [
{ country: 'FR', lat: 20, lon: 0, count: 90, byService: { models: 90 } },
{ country: 'US', lat: 37.09, lon: -95.71, count: 42, byService: { models: 42 } },
{ country: 'JP', lat: 20, lon: 180, count: 30, byService: { models: 30 } },
],
totals: { rps_1m: 1.6, rpm_60m: 96, top_countries: [{ country: 'FR', count: 90 }, { country: 'US', count: 42 }, { country: 'JP', count: 30 }] },
},
traffic: {
updatedAt: '2026-07-18T12:00:00Z', demo: false,
arcs: [
{ fromLat: 20, fromLon: 0, toLat: 51.5, toLon: -0.12, weight: 1, label: 'FR → lon' },
{ fromLat: 37.09, fromLon: -95.71, toLat: 40.71, toLon: -74.0, weight: 0.6, label: 'US → nyc' },
],
},
chainNodes: {
updatedAt: '2026-07-18T12:00:00Z', positionsModeled: true,
networks: [{ id: 'lux', name: 'Lux Network', chainId: 96369, blockHeight: 1096461, peers: 3, live: true,
nodes: [{ lat: 40.71, lon: -74.0, city: 'New York', kind: 'validator' }, { lat: 37.77, lon: -122.42, city: 'San Francisco', kind: 'validator' }] }],
},
byoGpu: { updatedAt: '2026-07-18T12:00:00Z', demo: false, gpus: [] },
};
// The flagship Cloud variant now OPENS on the native 3D globe (its immersive default),
// which parks the mapbox 2D map — `.deckgl-map-wrapper` mounts hidden. These specs
// exercise the 2D→3D toggle path (2D layer parity, then go3D), so they pin the start
// to 2D with `?mode=2d` (resolveInitialMapMode: the URL wins over the variant default).
// The globe-as-default render is proven separately (direct visual + the live prod shot).
const CLOUD_2D = '/?variant=cloud&mode=2d';
async function mockCloud(page: import('@playwright/test').Page, traffic = cloudMap.trafficGlobe): Promise<void> {
const json = (body: unknown) => ({ status: 200, contentType: 'application/json', body: JSON.stringify(body) });
await page.route('**/v1/world/cloud/traffic-globe', (r) => r.fulfill(json(traffic)));
await page.route('**/v1/world/cloud/traffic', (r) => r.fulfill(json(cloudMap.traffic)));
await page.route('**/v1/world/cloud/chain-nodes', (r) => r.fulfill(json(cloudMap.chainNodes)));
await page.route('**/v1/world/cloud/byo-gpu', (r) => r.fulfill(json(cloudMap.byoGpu)));
}
const layerIds = (page: import('@playwright/test').Page): Promise<string[]> =>
page.evaluate(() => {
const m = (window as unknown as { __deckMap?: { asGlobeSource: () => { buildLayers: () => DeckLayer[] } } }).__deckMap;
return (m?.asGlobeSource().buildLayers() ?? []).flat(Infinity).filter(Boolean).map((l: DeckLayer) => l.id);
});
const go3D = async (page: import('@playwright/test').Page): Promise<void> => {
// Enter the 3D globe through the projection toggle's REAL click handler
// (proj-btn → setProjectionMode('3d') → activateNativeGlobe). The flagship Cloud
// view floats its control dock over the full-viewport globe canvas, so under
// headless swiftshader a synthesized mouse click's hit-test lands on the canvas,
// not the collapsed-dropdown button — dispatch the click on the element itself.
// This spec verifies the GLOBE render (occlusion/terrain/parity/live dots); the
// dock's pointer reachability is a separate controls concern, out of scope here.
await page.evaluate(() => {
const btn = document.querySelector('.deckgl-projection-toggle .proj-btn[data-mode="3d"]') as HTMLElement | null;
btn?.click();
});
await expect
.poll(() => page.evaluate(() => Boolean((window as unknown as { __globeNative?: unknown }).__globeNative)), { timeout: 25000 })
.toBe(true);
await page.waitForTimeout(1200); // let the first data-sync pull + push layers
};
test.describe('Globe render fixes — occlusion, terrain, 2D/3D parity, live dots', () => {
test.describe.configure({ retries: 1 });
test.use({ reducedMotion: 'reduce' });
// The cloud data layers that must exist on the Hanzo-Cloud globe once feeds resolve.
const CLOUD_LAYERS = ['traffic', 'trafficArcs', 'chainNodes', 'datacenter-clusters-layer'];
test('3D: cloud layers mount, live dots render on the sphere, no WebGL errors', async ({ page }, testInfo) => {
const errors: string[] = [];
const ignorable = [/could not compile fragment shader/i, /image.*could not be decoded/i, /the layer 'background'/i, /status of 40[13]/i];
page.on('console', (m) => { if (m.type() === 'error') errors.push(m.text()); });
page.on('pageerror', (e) => errors.push(e.message));
await mockCloud(page);
await page.goto(CLOUD_2D);
await expect(page.locator('.deckgl-map-wrapper')).toBeVisible({ timeout: 45000 });
await go3D(page);
// Single WebGL context for the globe (deck GlobeView, not a 2nd overlay canvas).
expect(await page.evaluate(() => document.querySelectorAll('.globe-native-canvas').length)).toBe(1);
await expect.poll(() => page.evaluate(() => (window as unknown as { __globeNative: { getViewportType: () => string | null } }).__globeNative.getViewportType())).toMatch(/Globe/i);
// Every cloud data layer is mounted.
const ids = await layerIds(page);
for (const id of CLOUD_LAYERS) expect(ids).toContain(id);
// The live request-geo dots are present with the real (mocked) point count.
const trafficCount = await page.evaluate(() => {
const m = (window as unknown as { __deckMap: { asGlobeSource: () => { buildLayers: () => DeckLayer[] } } }).__deckMap;
const t = m.asGlobeSource().buildLayers().flat(Infinity).find((l: DeckLayer) => l?.id === 'traffic');
return (t?.props?.data as unknown[])?.length ?? 0;
});
expect(trafficCount).toBe(cloudMap.trafficGlobe.points.length);
const shot = await page.locator('.globe-native-wrapper').screenshot();
await testInfo.attach('3d-globe-cloud-layers', { body: shot, contentType: 'image/png' });
expect(errors.filter((e) => !ignorable.some((p) => p.test(e)))).toEqual([]);
});
test('occlusion: a back-hemisphere dot is culled to transparent, a front dot is opaque', async ({ page }) => {
await mockCloud(page);
await page.goto(CLOUD_2D);
await expect(page.locator('.deckgl-map-wrapper')).toBeVisible({ timeout: 45000 });
await go3D(page);
// Face the camera at lon 0 (the FRONT point). Read the REAL traffic layer's fill
// accessor for each point: front → opaque (alpha>0), back (lon 180) → alpha 0.
const alphas = await page.evaluate(() => {
const m = (window as unknown as { __deckMap: { setOcclusionCenter: (lng: number, lat: number) => void; asGlobeSource: () => { buildLayers: () => DeckLayer[] } } }).__deckMap;
m.setOcclusionCenter(0, 20);
const t = m.asGlobeSource().buildLayers().flat(Infinity).find((l: DeckLayer) => l?.id === 'traffic') as DeckLayer;
const data = t.props.data as Array<{ lon: number }>;
const getFill = t.props.getFillColor as (d: unknown) => Rgba;
const front = getFill(data.find((d) => d.lon === 0));
const back = getFill(data.find((d) => d.lon === 180));
return { frontAlpha: front[3], backAlpha: back[3] };
});
expect(alphas.frontAlpha).toBeGreaterThan(0);
expect(alphas.backAlpha).toBe(0);
});
test('occlusion: a back-hemisphere count badge is culled (no floating badge over the globe)', async ({ page }) => {
await mockCloud(page);
await page.goto(CLOUD_2D);
await expect(page.locator('.deckgl-map-wrapper')).toBeVisible({ timeout: 45000 });
await go3D(page);
// datacenter count badges are a TextLayer; a back-side badge's glyph AND pill must
// both go transparent. Probe both hemispheres against a fixed camera centre.
const badge = await page.evaluate(() => {
const m = (window as unknown as { __deckMap: { setOcclusionCenter: (lng: number, lat: number) => void; asGlobeSource: () => { buildLayers: () => DeckLayer[] } } }).__deckMap;
m.setOcclusionCenter(0, 20);
const b = m.asGlobeSource().buildLayers().flat(Infinity).find((l: DeckLayer) => l?.id === 'datacenter-clusters-badge') as DeckLayer | undefined;
if (!b) return { skip: true };
const getColor = b.props.getColor as (d: unknown) => Rgba;
const getBg = b.props.getBackgroundColor as (d: unknown) => Rgba;
const front = { lon: 0, lat: 20, count: 5 };
const back = { lon: 180, lat: 20, count: 5 };
return { skip: false, frontText: getColor(front)[3], backText: getColor(back)[3], backBg: getBg(back)[3] };
});
if (badge.skip) test.skip(true, 'no datacenter badge in this build');
expect(badge.frontText).toBeGreaterThan(0);
expect(badge.backText).toBe(0);
expect(badge.backBg).toBe(0);
});
test('2D/3D parity: every cloud layer that mounts in 3D also mounts in 2D', async ({ page }, testInfo) => {
await mockCloud(page);
await page.goto(CLOUD_2D);
await expect(page.locator('.deckgl-map-wrapper')).toBeVisible({ timeout: 45000 });
await page.waitForTimeout(1500); // 2D feeds settle
const ids2d = await layerIds(page);
for (const id of CLOUD_LAYERS) expect(ids2d, `2D missing ${id}`).toContain(id);
await testInfo.attach('2d-map-cloud-layers', { body: await page.locator('.deckgl-map-wrapper').screenshot(), contentType: 'image/png' });
await go3D(page);
const ids3d = await layerIds(page);
for (const id of CLOUD_LAYERS) expect(ids3d, `3D missing ${id}`).toContain(id);
await testInfo.attach('3d-globe-cloud-layers-parity', { body: await page.locator('.globe-native-wrapper').screenshot(), contentType: 'image/png' });
});
test('terrain drapes on the globe in a single context (no second canvas)', async ({ page }, testInfo) => {
await mockCloud(page);
await page.goto(CLOUD_2D);
await expect(page.locator('.deckgl-map-wrapper')).toBeVisible({ timeout: 45000 });
await go3D(page);
await page.evaluate(() => (window as unknown as { __globeNative: { setBasemapStyle: (s: string) => void } }).__globeNative.setBasemapStyle('terrain'));
await expect
.poll(() => page.evaluate(() => (window as unknown as { __globeNative: { getDeck: () => { props: { layers: DeckLayer[] } } } }).__globeNative.getDeck().props.layers.flat(Infinity).some((l: DeckLayer) => l?.id?.startsWith('globe-imagery-terrain'))), { timeout: 20000 })
.toBe(true);
// Still exactly one canvas — imagery drapes as deck tiles, not a 2nd WebGL context.
expect(await page.evaluate(() => document.querySelectorAll('.globe-native-wrapper canvas').length)).toBe(1);
await page.waitForTimeout(2500);
await testInfo.attach('3d-terrain-globe', { body: await page.locator('.globe-native-wrapper').screenshot(), contentType: 'image/png' });
});
test('live dots update when the feed changes', async ({ page }) => {
await mockCloud(page, { ...cloudMap.trafficGlobe, points: cloudMap.trafficGlobe.points.slice(0, 1) });
await page.goto(CLOUD_2D);
await expect(page.locator('.deckgl-map-wrapper')).toBeVisible({ timeout: 45000 });
await go3D(page);
const count = () => page.evaluate(() => {
const m = (window as unknown as { __deckMap: { asGlobeSource: () => { buildLayers: () => DeckLayer[] } } }).__deckMap;
const t = m.asGlobeSource().buildLayers().flat(Infinity).find((l: DeckLayer) => l?.id === 'traffic');
return (t?.props?.data as unknown[])?.length ?? 0;
});
await expect.poll(count, { timeout: 15000 }).toBe(1);
// Swap the feed to the full 3-point payload; the DeckGLMap poll picks it up.
await mockCloud(page, cloudMap.trafficGlobe);
await expect.poll(count, { timeout: 20000 }).toBe(cloudMap.trafficGlobe.points.length);
});
});