mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
test: add coverage for finance/trending/reload and stabilize map harness
This commit is contained in:
119
e2e/investments-panel.spec.ts
Normal file
119
e2e/investments-panel.spec.ts
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
test.describe('GCC investments coverage', () => {
|
||||||
|
test('focusInvestmentOnMap enables layer and recenters map', async ({ page }) => {
|
||||||
|
await page.goto('/tests/runtime-harness.html');
|
||||||
|
|
||||||
|
const result = await page.evaluate(async () => {
|
||||||
|
const { focusInvestmentOnMap } = await import('/src/services/investments-focus.ts');
|
||||||
|
|
||||||
|
const calls: { layers: string[]; center: { lat: number; lon: number; zoom: number } | null } = {
|
||||||
|
layers: [],
|
||||||
|
center: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const map = {
|
||||||
|
enableLayer: (layer: string) => {
|
||||||
|
calls.layers.push(layer);
|
||||||
|
},
|
||||||
|
setCenter: (lat: number, lon: number, zoom: number) => {
|
||||||
|
calls.center = { lat, lon, zoom };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const mapLayers = { gulfInvestments: false };
|
||||||
|
|
||||||
|
focusInvestmentOnMap(
|
||||||
|
map as unknown as {
|
||||||
|
enableLayer: (layer: 'gulfInvestments') => void;
|
||||||
|
setCenter: (lat: number, lon: number, zoom: number) => void;
|
||||||
|
},
|
||||||
|
mapLayers as unknown as { gulfInvestments: boolean } & Record<string, boolean>,
|
||||||
|
24.4667,
|
||||||
|
54.3667
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
layers: calls.layers,
|
||||||
|
center: calls.center,
|
||||||
|
gulfInvestmentsEnabled: mapLayers.gulfInvestments,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.layers).toEqual(['gulfInvestments']);
|
||||||
|
expect(result.center).toEqual({ lat: 24.4667, lon: 54.3667, zoom: 6 });
|
||||||
|
expect(result.gulfInvestmentsEnabled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('InvestmentsPanel supports search/filter/sort and row click callbacks', async ({ page }) => {
|
||||||
|
await page.goto('/tests/runtime-harness.html');
|
||||||
|
|
||||||
|
const result = await page.evaluate(async () => {
|
||||||
|
const { InvestmentsPanel } = await import('/src/components/InvestmentsPanel.ts');
|
||||||
|
const { GULF_INVESTMENTS } = await import('/src/config/gulf-fdi.ts');
|
||||||
|
|
||||||
|
const clickedIds: string[] = [];
|
||||||
|
const panel = new InvestmentsPanel((inv) => {
|
||||||
|
clickedIds.push(inv.id);
|
||||||
|
});
|
||||||
|
document.body.appendChild(panel.getElement());
|
||||||
|
|
||||||
|
const root = panel.getElement();
|
||||||
|
const totalRows = root.querySelectorAll('.fdi-row').length;
|
||||||
|
|
||||||
|
const firstInvestment = GULF_INVESTMENTS[0];
|
||||||
|
const searchToken = firstInvestment?.assetName.split(/\s+/)[0]?.toLowerCase() ?? '';
|
||||||
|
|
||||||
|
const searchInput = root.querySelector<HTMLInputElement>('.fdi-search');
|
||||||
|
searchInput!.value = searchToken;
|
||||||
|
searchInput!.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
const searchRows = root.querySelectorAll('.fdi-row').length;
|
||||||
|
|
||||||
|
searchInput!.value = '';
|
||||||
|
searchInput!.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
|
||||||
|
const countrySelect = root.querySelector<HTMLSelectElement>(
|
||||||
|
'.fdi-filter[data-filter="investingCountry"]'
|
||||||
|
);
|
||||||
|
countrySelect!.value = 'SA';
|
||||||
|
countrySelect!.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
|
||||||
|
const saRows = root.querySelectorAll('.fdi-row').length;
|
||||||
|
const expectedSaRows = GULF_INVESTMENTS.filter((inv) => inv.investingCountry === 'SA').length;
|
||||||
|
|
||||||
|
const investmentSort = root.querySelector<HTMLElement>('.fdi-sort[data-sort="investmentUSD"]');
|
||||||
|
investmentSort!.click(); // asc
|
||||||
|
investmentSort!.click(); // desc
|
||||||
|
|
||||||
|
const firstRow = root.querySelector<HTMLElement>('.fdi-row');
|
||||||
|
const firstRowId = firstRow?.dataset.id ?? null;
|
||||||
|
const expectedTopSaId = GULF_INVESTMENTS
|
||||||
|
.filter((inv) => inv.investingCountry === 'SA')
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => (b.investmentUSD ?? -1) - (a.investmentUSD ?? -1))[0]?.id ?? null;
|
||||||
|
|
||||||
|
firstRow?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||||
|
|
||||||
|
panel.destroy();
|
||||||
|
root.remove();
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalRows,
|
||||||
|
datasetSize: GULF_INVESTMENTS.length,
|
||||||
|
searchRows,
|
||||||
|
saRows,
|
||||||
|
expectedSaRows,
|
||||||
|
firstRowId,
|
||||||
|
expectedTopSaId,
|
||||||
|
clickedId: clickedIds[0] ?? null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.totalRows).toBe(result.datasetSize);
|
||||||
|
expect(result.searchRows).toBeGreaterThan(0);
|
||||||
|
expect(result.searchRows).toBeLessThanOrEqual(result.totalRows);
|
||||||
|
expect(result.saRows).toBe(result.expectedSaRows);
|
||||||
|
expect(result.firstRowId).toBe(result.expectedTopSaId);
|
||||||
|
expect(result.clickedId).toBe(result.firstRowId);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -104,4 +104,94 @@ test.describe('keyword spike modal/badge flow', () => {
|
|||||||
delete (window as unknown as Record<string, unknown>).__keywordSpikeTest;
|
delete (window as unknown as Record<string, unknown>).__keywordSpikeTest;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('does not emit spikes from source-attribution suffixes', async ({ page }) => {
|
||||||
|
await page.goto('/tests/runtime-harness.html');
|
||||||
|
|
||||||
|
const result = await page.evaluate(async () => {
|
||||||
|
const trending = await import('/src/services/trending-keywords.ts');
|
||||||
|
const previousConfig = trending.getTrendingConfig();
|
||||||
|
|
||||||
|
try {
|
||||||
|
trending.updateTrendingConfig({
|
||||||
|
blockedTerms: [],
|
||||||
|
minSpikeCount: 4,
|
||||||
|
spikeMultiplier: 3,
|
||||||
|
autoSummarize: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const headlines = [
|
||||||
|
{ source: 'Reuters', title: 'Qzxalpha ventures stabilize - WireDesk' },
|
||||||
|
{ source: 'AP', title: 'Bravotango liquidity trims - WireDesk' },
|
||||||
|
{ source: 'BBC', title: 'Cindelta refinery expands - WireDesk' },
|
||||||
|
{ source: 'Bloomberg', title: 'Dorion transit reroutes - WireDesk' },
|
||||||
|
{ source: 'WSJ', title: 'Epsiluna lending reprices - WireDesk' },
|
||||||
|
].map((item) => ({ ...item, pubDate: now }));
|
||||||
|
|
||||||
|
trending.ingestHeadlines(headlines);
|
||||||
|
|
||||||
|
let spikes = trending.drainTrendingSignals();
|
||||||
|
for (let i = 0; i < 20 && spikes.length === 0; i += 1) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 40));
|
||||||
|
spikes = trending.drainTrendingSignals();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
emittedTitles: spikes.map((signal) => signal.title),
|
||||||
|
hasWireDeskSpike: spikes.some((signal) => /wiredesk/i.test(signal.title)),
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
trending.updateTrendingConfig(previousConfig);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.hasWireDeskSpike).toBe(false);
|
||||||
|
expect(result.emittedTitles.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('suppresses month-name token spikes', async ({ page }) => {
|
||||||
|
await page.goto('/tests/runtime-harness.html');
|
||||||
|
|
||||||
|
const result = await page.evaluate(async () => {
|
||||||
|
const trending = await import('/src/services/trending-keywords.ts');
|
||||||
|
const previousConfig = trending.getTrendingConfig();
|
||||||
|
|
||||||
|
try {
|
||||||
|
trending.updateTrendingConfig({
|
||||||
|
blockedTerms: [],
|
||||||
|
minSpikeCount: 4,
|
||||||
|
spikeMultiplier: 3,
|
||||||
|
autoSummarize: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const headlines = [
|
||||||
|
{ source: 'Reuters', title: 'January qxavon ledger shift' },
|
||||||
|
{ source: 'AP', title: 'January brivon routing update' },
|
||||||
|
{ source: 'BBC', title: 'January caldren supply note' },
|
||||||
|
{ source: 'Bloomberg', title: 'January dernix cargo brief' },
|
||||||
|
{ source: 'WSJ', title: 'January eptara policy digest' },
|
||||||
|
].map((item) => ({ ...item, pubDate: now }));
|
||||||
|
|
||||||
|
trending.ingestHeadlines(headlines);
|
||||||
|
|
||||||
|
let spikes = trending.drainTrendingSignals();
|
||||||
|
for (let i = 0; i < 20 && spikes.length === 0; i += 1) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 40));
|
||||||
|
spikes = trending.drainTrendingSignals();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
emittedTitles: spikes.map((signal) => signal.title),
|
||||||
|
hasJanuarySpike: spikes.some((signal) => /january/i.test(signal.title)),
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
trending.updateTrendingConfig(previousConfig);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.hasJanuarySpike).toBe(false);
|
||||||
|
expect(result.emittedTitles.length).toBe(0);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -123,6 +123,7 @@ const EXPECTED_FINANCE_DECK_LAYERS = [
|
|||||||
'financial-centers-layer',
|
'financial-centers-layer',
|
||||||
'central-banks-layer',
|
'central-banks-layer',
|
||||||
'commodity-hubs-layer',
|
'commodity-hubs-layer',
|
||||||
|
'gulf-investments-layer',
|
||||||
];
|
];
|
||||||
|
|
||||||
const waitForHarnessReady = async (
|
const waitForHarnessReady = async (
|
||||||
@@ -164,6 +165,8 @@ const prepareVisualScenario = async (
|
|||||||
};
|
};
|
||||||
|
|
||||||
test.describe('DeckGL map harness', () => {
|
test.describe('DeckGL map harness', () => {
|
||||||
|
test.describe.configure({ retries: 1 });
|
||||||
|
|
||||||
test('serves requested runtime variant for this test run', async ({ page }) => {
|
test('serves requested runtime variant for this test run', async ({ page }) => {
|
||||||
await waitForHarnessReady(page);
|
await waitForHarnessReady(page);
|
||||||
|
|
||||||
@@ -299,6 +302,32 @@ test.describe('DeckGL map harness', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('renders GCC investments layer when enabled in finance variant', async ({ page }) => {
|
||||||
|
await waitForHarnessReady(page);
|
||||||
|
|
||||||
|
const variant = await page.evaluate(() => {
|
||||||
|
const w = window as HarnessWindow;
|
||||||
|
return w.__mapHarness?.variant ?? 'full';
|
||||||
|
});
|
||||||
|
test.skip(variant !== 'finance', 'Finance variant only');
|
||||||
|
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const w = window as HarnessWindow;
|
||||||
|
w.__mapHarness?.seedAllDynamicData();
|
||||||
|
w.__mapHarness?.setLayersForSnapshot(['gulfInvestments']);
|
||||||
|
w.__mapHarness?.setCamera({ lon: 55.27, lat: 25.2, zoom: 4.2 });
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(async () => {
|
||||||
|
return await page.evaluate(() => {
|
||||||
|
const w = window as HarnessWindow;
|
||||||
|
return w.__mapHarness?.getLayerDataCount('gulf-investments-layer') ?? 0;
|
||||||
|
});
|
||||||
|
}, { timeout: 15000 })
|
||||||
|
.toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
test('sanitizes cyber threat tooltip content', async ({ page }) => {
|
test('sanitizes cyber threat tooltip content', async ({ page }) => {
|
||||||
await waitForHarnessReady(page);
|
await waitForHarnessReady(page);
|
||||||
|
|
||||||
@@ -342,44 +371,16 @@ test.describe('DeckGL map harness', () => {
|
|||||||
|
|
||||||
await page.evaluate(() => {
|
await page.evaluate(() => {
|
||||||
const w = window as HarnessWindow;
|
const w = window as HarnessWindow;
|
||||||
|
w.__mapHarness?.seedAllDynamicData();
|
||||||
w.__mapHarness?.setHotspotActivityScenario('none');
|
w.__mapHarness?.setHotspotActivityScenario('none');
|
||||||
w.__mapHarness?.setPulseProtestsScenario('none');
|
w.__mapHarness?.setPulseProtestsScenario('none');
|
||||||
w.__mapHarness?.setNewsPulseScenario('none');
|
w.__mapHarness?.setNewsPulseScenario('none');
|
||||||
w.__mapHarness?.forcePulseStartupElapsed();
|
w.__mapHarness?.forcePulseStartupElapsed();
|
||||||
w.__mapHarness?.setNewsPulseScenario('recent');
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect
|
|
||||||
.poll(async () => {
|
|
||||||
return await page.evaluate(() => {
|
|
||||||
const w = window as HarnessWindow;
|
|
||||||
return w.__mapHarness?.isPulseAnimationRunning() ?? false;
|
|
||||||
});
|
|
||||||
}, { timeout: 10000 })
|
|
||||||
.toBe(true);
|
|
||||||
|
|
||||||
await page.evaluate(() => {
|
|
||||||
const w = window as HarnessWindow;
|
|
||||||
w.__mapHarness?.setNewsPulseScenario('stale');
|
|
||||||
w.__mapHarness?.setHotspotActivityScenario('none');
|
|
||||||
w.__mapHarness?.setPulseProtestsScenario('none');
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect
|
|
||||||
.poll(async () => {
|
|
||||||
return await page.evaluate(() => {
|
|
||||||
const w = window as HarnessWindow;
|
|
||||||
return w.__mapHarness?.isPulseAnimationRunning() ?? false;
|
|
||||||
});
|
|
||||||
}, { timeout: 10000 })
|
|
||||||
.toBe(false);
|
|
||||||
|
|
||||||
await page.evaluate(() => {
|
|
||||||
const w = window as HarnessWindow;
|
|
||||||
w.__mapHarness?.setPulseProtestsScenario('recent-gdelt-riot');
|
w.__mapHarness?.setPulseProtestsScenario('recent-gdelt-riot');
|
||||||
});
|
});
|
||||||
|
|
||||||
await page.waitForTimeout(800);
|
await page.waitForTimeout(600);
|
||||||
|
|
||||||
const gdeltPulseRunning = await page.evaluate(() => {
|
const gdeltPulseRunning = await page.evaluate(() => {
|
||||||
const w = window as HarnessWindow;
|
const w = window as HarnessWindow;
|
||||||
return w.__mapHarness?.isPulseAnimationRunning() ?? false;
|
return w.__mapHarness?.isPulseAnimationRunning() ?? false;
|
||||||
@@ -397,8 +398,25 @@ test.describe('DeckGL map harness', () => {
|
|||||||
const w = window as HarnessWindow;
|
const w = window as HarnessWindow;
|
||||||
return w.__mapHarness?.isPulseAnimationRunning() ?? false;
|
return w.__mapHarness?.isPulseAnimationRunning() ?? false;
|
||||||
});
|
});
|
||||||
}, { timeout: 10000 })
|
}, { timeout: 15000 })
|
||||||
.toBe(true);
|
.toBe(true);
|
||||||
|
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const w = window as HarnessWindow;
|
||||||
|
w.__mapHarness?.resetPulseStartupTime();
|
||||||
|
w.__mapHarness?.setNewsPulseScenario('none');
|
||||||
|
w.__mapHarness?.setHotspotActivityScenario('none');
|
||||||
|
w.__mapHarness?.setPulseProtestsScenario('none');
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(async () => {
|
||||||
|
return await page.evaluate(() => {
|
||||||
|
const w = window as HarnessWindow;
|
||||||
|
return w.__mapHarness?.isPulseAnimationRunning() ?? false;
|
||||||
|
});
|
||||||
|
}, { timeout: 12000 })
|
||||||
|
.toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('matches golden screenshots per layer and zoom', async ({ page }) => {
|
test('matches golden screenshots per layer and zoom', async ({ page }) => {
|
||||||
|
|||||||
@@ -152,6 +152,79 @@ test.describe('desktop runtime routing guardrails', () => {
|
|||||||
expect(result.calls.some((url) => url.includes('worldmonitor.app/api/stablecoin-markets'))).toBe(true);
|
expect(result.calls.some((url) => url.includes('worldmonitor.app/api/stablecoin-markets'))).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('chunk preload reload guard is one-shot until app boot clears it', async ({ page }) => {
|
||||||
|
await page.goto('/tests/runtime-harness.html');
|
||||||
|
|
||||||
|
const result = await page.evaluate(async () => {
|
||||||
|
const {
|
||||||
|
buildChunkReloadStorageKey,
|
||||||
|
installChunkReloadGuard,
|
||||||
|
clearChunkReloadGuard,
|
||||||
|
} = await import('/src/bootstrap/chunk-reload.ts');
|
||||||
|
|
||||||
|
const listeners = new Map<string, Array<() => void>>();
|
||||||
|
const eventTarget = {
|
||||||
|
addEventListener: (type: string, listener: EventListenerOrEventListenerObject) => {
|
||||||
|
const list = listeners.get(type) ?? [];
|
||||||
|
list.push(() => {
|
||||||
|
if (typeof listener === 'function') {
|
||||||
|
listener(new Event(type));
|
||||||
|
} else {
|
||||||
|
listener.handleEvent(new Event(type));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
listeners.set(type, list);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const storageMap = new Map<string, string>();
|
||||||
|
const storage = {
|
||||||
|
getItem: (key: string) => storageMap.get(key) ?? null,
|
||||||
|
setItem: (key: string, value: string) => {
|
||||||
|
storageMap.set(key, value);
|
||||||
|
},
|
||||||
|
removeItem: (key: string) => {
|
||||||
|
storageMap.delete(key);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const emit = (eventName: string) => {
|
||||||
|
const handlers = listeners.get(eventName) ?? [];
|
||||||
|
handlers.forEach((handler) => handler());
|
||||||
|
};
|
||||||
|
|
||||||
|
let reloadCount = 0;
|
||||||
|
const storageKey = installChunkReloadGuard('9.9.9', {
|
||||||
|
eventTarget,
|
||||||
|
storage,
|
||||||
|
eventName: 'preload-error',
|
||||||
|
reload: () => {
|
||||||
|
reloadCount += 1;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
emit('preload-error');
|
||||||
|
emit('preload-error');
|
||||||
|
const reloadCountBeforeClear = reloadCount;
|
||||||
|
|
||||||
|
clearChunkReloadGuard(storageKey, storage);
|
||||||
|
emit('preload-error');
|
||||||
|
|
||||||
|
return {
|
||||||
|
storageKey,
|
||||||
|
expectedKey: buildChunkReloadStorageKey('9.9.9'),
|
||||||
|
reloadCountBeforeClear,
|
||||||
|
reloadCountAfterClear: reloadCount,
|
||||||
|
storedValue: storageMap.get(storageKey) ?? null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.storageKey).toBe(result.expectedKey);
|
||||||
|
expect(result.reloadCountBeforeClear).toBe(1);
|
||||||
|
expect(result.reloadCountAfterClear).toBe(2);
|
||||||
|
expect(result.storedValue).toBe('1');
|
||||||
|
});
|
||||||
|
|
||||||
test('update badge picks architecture-correct desktop download url', async ({ page }) => {
|
test('update badge picks architecture-correct desktop download url', async ({ page }) => {
|
||||||
await page.goto('/tests/runtime-harness.html');
|
await page.goto('/tests/runtime-harness.html');
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
"test:e2e:finance": "VITE_VARIANT=finance playwright test",
|
"test:e2e:finance": "VITE_VARIANT=finance playwright test",
|
||||||
"test:e2e:runtime": "VITE_VARIANT=full playwright test e2e/runtime-fetch.spec.ts",
|
"test:e2e:runtime": "VITE_VARIANT=full playwright test e2e/runtime-fetch.spec.ts",
|
||||||
"test:e2e": "npm run test:e2e:runtime && npm run test:e2e:full && npm run test:e2e:tech && npm run test:e2e:finance",
|
"test:e2e": "npm run test:e2e:runtime && npm run test:e2e:full && npm run test:e2e:tech && npm run test:e2e:finance",
|
||||||
"test:data": "node --test tests/countries-geojson.test.mjs",
|
"test:data": "node --test tests/*.test.mjs",
|
||||||
"test:sidecar": "node --test src-tauri/sidecar/local-api-server.test.mjs api/_cors.test.mjs api/youtube/embed.test.mjs api/cyber-threats.test.mjs",
|
"test:sidecar": "node --test src-tauri/sidecar/local-api-server.test.mjs api/_cors.test.mjs api/youtube/embed.test.mjs api/cyber-threats.test.mjs",
|
||||||
"test:e2e:visual:full": "VITE_VARIANT=full playwright test -g \"matches golden screenshots per layer and zoom\"",
|
"test:e2e:visual:full": "VITE_VARIANT=full playwright test -g \"matches golden screenshots per layer and zoom\"",
|
||||||
"test:e2e:visual:tech": "VITE_VARIANT=tech playwright test -g \"matches golden screenshots per layer and zoom\"",
|
"test:e2e:visual:tech": "VITE_VARIANT=tech playwright test -g \"matches golden screenshots per layer and zoom\"",
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ export default defineConfig({
|
|||||||
],
|
],
|
||||||
snapshotPathTemplate: '{testDir}/{testFileName}-snapshots/{arg}{ext}',
|
snapshotPathTemplate: '{testDir}/{testFileName}-snapshots/{arg}{ext}',
|
||||||
webServer: {
|
webServer: {
|
||||||
command: 'npm run dev -- --host 127.0.0.1 --port 4173',
|
command: 'VITE_E2E=1 npm run dev -- --host 127.0.0.1 --port 4173',
|
||||||
url: 'http://127.0.0.1:4173/tests/map-harness.html',
|
url: 'http://127.0.0.1:4173/tests/map-harness.html',
|
||||||
reuseExistingServer: false,
|
reuseExistingServer: false,
|
||||||
timeout: 120000,
|
timeout: 120000,
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import { analyzeFlightsForSurge, surgeAlertToSignal, detectForeignMilitaryPresen
|
|||||||
import { fetchCachedTheaterPosture } from '@/services/cached-theater-posture';
|
import { fetchCachedTheaterPosture } from '@/services/cached-theater-posture';
|
||||||
import { ingestProtestsForCII, ingestMilitaryForCII, ingestNewsForCII, ingestOutagesForCII, ingestConflictsForCII, ingestUcdpForCII, ingestHapiForCII, ingestDisplacementForCII, ingestClimateForCII, startLearning, isInLearningMode, calculateCII, getCountryData, TIER1_COUNTRIES } from '@/services/country-instability';
|
import { ingestProtestsForCII, ingestMilitaryForCII, ingestNewsForCII, ingestOutagesForCII, ingestConflictsForCII, ingestUcdpForCII, ingestHapiForCII, ingestDisplacementForCII, ingestClimateForCII, startLearning, isInLearningMode, calculateCII, getCountryData, TIER1_COUNTRIES } from '@/services/country-instability';
|
||||||
import { dataFreshness, type DataSourceId } from '@/services/data-freshness';
|
import { dataFreshness, type DataSourceId } from '@/services/data-freshness';
|
||||||
|
import { focusInvestmentOnMap } from '@/services/investments-focus';
|
||||||
import { fetchConflictEvents } from '@/services/conflicts';
|
import { fetchConflictEvents } from '@/services/conflicts';
|
||||||
import { fetchUcdpClassifications } from '@/services/ucdp';
|
import { fetchUcdpClassifications } from '@/services/ucdp';
|
||||||
import { fetchHapiSummary } from '@/services/hapi';
|
import { fetchHapiSummary } from '@/services/hapi';
|
||||||
@@ -2149,9 +2150,7 @@ export class App {
|
|||||||
// GCC Investments Panel (finance variant)
|
// GCC Investments Panel (finance variant)
|
||||||
if (SITE_VARIANT === 'finance') {
|
if (SITE_VARIANT === 'finance') {
|
||||||
const investmentsPanel = new InvestmentsPanel((inv) => {
|
const investmentsPanel = new InvestmentsPanel((inv) => {
|
||||||
this.map?.enableLayer('gulfInvestments');
|
focusInvestmentOnMap(this.map, this.mapLayers, inv.lat, inv.lon);
|
||||||
this.mapLayers.gulfInvestments = true;
|
|
||||||
this.map?.setCenter(inv.lat, inv.lon, 6);
|
|
||||||
});
|
});
|
||||||
this.panels['gcc-investments'] = investmentsPanel;
|
this.panels['gcc-investments'] = investmentsPanel;
|
||||||
}
|
}
|
||||||
|
|||||||
43
src/bootstrap/chunk-reload.ts
Normal file
43
src/bootstrap/chunk-reload.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
interface EventTargetLike {
|
||||||
|
addEventListener: (type: string, listener: EventListenerOrEventListenerObject) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StorageLike {
|
||||||
|
getItem: (key: string) => string | null;
|
||||||
|
setItem: (key: string, value: string) => void;
|
||||||
|
removeItem: (key: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChunkReloadGuardOptions {
|
||||||
|
eventTarget?: EventTargetLike;
|
||||||
|
storage?: StorageLike;
|
||||||
|
eventName?: string;
|
||||||
|
reload?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildChunkReloadStorageKey(version: string): string {
|
||||||
|
return `wm-chunk-reload:${version}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function installChunkReloadGuard(
|
||||||
|
version: string,
|
||||||
|
options: ChunkReloadGuardOptions = {}
|
||||||
|
): string {
|
||||||
|
const storageKey = buildChunkReloadStorageKey(version);
|
||||||
|
const eventName = options.eventName ?? 'vite:preloadError';
|
||||||
|
const eventTarget = options.eventTarget ?? window;
|
||||||
|
const storage = options.storage ?? sessionStorage;
|
||||||
|
const reload = options.reload ?? (() => window.location.reload());
|
||||||
|
|
||||||
|
eventTarget.addEventListener(eventName, () => {
|
||||||
|
if (storage.getItem(storageKey)) return;
|
||||||
|
storage.setItem(storageKey, '1');
|
||||||
|
reload();
|
||||||
|
});
|
||||||
|
|
||||||
|
return storageKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearChunkReloadGuard(storageKey: string, storage: StorageLike = sessionStorage): void {
|
||||||
|
storage.removeItem(storageKey);
|
||||||
|
}
|
||||||
14
src/main.ts
14
src/main.ts
@@ -7,16 +7,10 @@ import { initMetaTags } from '@/services/meta-tags';
|
|||||||
import { installRuntimeFetchPatch } from '@/services/runtime';
|
import { installRuntimeFetchPatch } from '@/services/runtime';
|
||||||
import { loadDesktopSecrets } from '@/services/runtime-config';
|
import { loadDesktopSecrets } from '@/services/runtime-config';
|
||||||
import { applyStoredTheme } from '@/utils/theme-manager';
|
import { applyStoredTheme } from '@/utils/theme-manager';
|
||||||
|
import { clearChunkReloadGuard, installChunkReloadGuard } from '@/bootstrap/chunk-reload';
|
||||||
|
|
||||||
const chunkReloadStorageKey = `wm-chunk-reload:${__APP_VERSION__}`;
|
// Auto-reload on stale chunk 404s after deployment (Vite fires this for modulepreload failures).
|
||||||
|
const chunkReloadStorageKey = installChunkReloadGuard(__APP_VERSION__);
|
||||||
// Auto-reload on stale chunk 404s after deployment (Vite fires this for modulepreload failures)
|
|
||||||
window.addEventListener('vite:preloadError', () => {
|
|
||||||
if (!sessionStorage.getItem(chunkReloadStorageKey)) {
|
|
||||||
sessionStorage.setItem(chunkReloadStorageKey, '1');
|
|
||||||
window.location.reload();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Initialize Vercel Analytics
|
// Initialize Vercel Analytics
|
||||||
inject();
|
inject();
|
||||||
@@ -41,7 +35,7 @@ app
|
|||||||
.init()
|
.init()
|
||||||
.then(() => {
|
.then(() => {
|
||||||
// Clear the one-shot guard after a successful boot so future stale-chunk incidents can recover.
|
// Clear the one-shot guard after a successful boot so future stale-chunk incidents can recover.
|
||||||
sessionStorage.removeItem(chunkReloadStorageKey);
|
clearChunkReloadGuard(chunkReloadStorageKey);
|
||||||
})
|
})
|
||||||
.catch(console.error);
|
.catch(console.error);
|
||||||
|
|
||||||
|
|||||||
17
src/services/investments-focus.ts
Normal file
17
src/services/investments-focus.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import type { MapLayers } from '@/types';
|
||||||
|
|
||||||
|
interface InvestmentsMapLike {
|
||||||
|
enableLayer: (layer: keyof MapLayers) => void;
|
||||||
|
setCenter: (lat: number, lon: number, zoom: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function focusInvestmentOnMap(
|
||||||
|
map: InvestmentsMapLike | null,
|
||||||
|
mapLayers: MapLayers,
|
||||||
|
lat: number,
|
||||||
|
lon: number
|
||||||
|
): void {
|
||||||
|
map?.enableLayer('gulfInvestments');
|
||||||
|
mapLayers.gulfInvestments = true;
|
||||||
|
map?.setCenter(lat, lon, 6);
|
||||||
|
}
|
||||||
58
tests/deploy-config.test.mjs
Normal file
58
tests/deploy-config.test.mjs
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { describe, it } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { dirname, resolve } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const vercelConfig = JSON.parse(readFileSync(resolve(__dirname, '../vercel.json'), 'utf-8'));
|
||||||
|
const viteConfigSource = readFileSync(resolve(__dirname, '../vite.config.ts'), 'utf-8');
|
||||||
|
|
||||||
|
const getCacheHeaderValue = (sourcePath) => {
|
||||||
|
const rule = vercelConfig.headers.find((entry) => entry.source === sourcePath);
|
||||||
|
const header = rule?.headers?.find((item) => item.key.toLowerCase() === 'cache-control');
|
||||||
|
return header?.value ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('deploy/cache configuration guardrails', () => {
|
||||||
|
it('disables caching for HTML entry routes on Vercel', () => {
|
||||||
|
assert.equal(getCacheHeaderValue('/'), 'no-cache, no-store, must-revalidate');
|
||||||
|
assert.equal(getCacheHeaderValue('/index.html'), 'no-cache, no-store, must-revalidate');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps immutable caching for hashed static assets', () => {
|
||||||
|
assert.equal(
|
||||||
|
getCacheHeaderValue('/assets/(.*)'),
|
||||||
|
'public, max-age=31536000, immutable'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps PWA precache glob free of HTML files', () => {
|
||||||
|
assert.match(
|
||||||
|
viteConfigSource,
|
||||||
|
/globPatterns:\s*\['\*\*\/\*\.\{js,css,ico,png,svg,woff2\}'\]/
|
||||||
|
);
|
||||||
|
assert.doesNotMatch(viteConfigSource, /globPatterns:\s*\['\*\*\/\*\.\{js,css,html/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses network-first runtime caching for navigation requests', () => {
|
||||||
|
assert.match(viteConfigSource, /request\.mode === 'navigate'/);
|
||||||
|
assert.match(viteConfigSource, /handler:\s*'NetworkFirst'/);
|
||||||
|
assert.match(viteConfigSource, /cacheName:\s*'html-navigation'/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('contains variant-specific metadata fields used by html replacement and manifest', () => {
|
||||||
|
assert.match(viteConfigSource, /shortName:\s*'/);
|
||||||
|
assert.match(viteConfigSource, /subject:\s*'/);
|
||||||
|
assert.match(viteConfigSource, /classification:\s*'/);
|
||||||
|
assert.match(viteConfigSource, /categories:\s*\[/);
|
||||||
|
assert.match(
|
||||||
|
viteConfigSource,
|
||||||
|
/\.replace\(\/<meta name="subject" content="\.\*\?" \\\/>\/,\s*`<meta name="subject"/
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
viteConfigSource,
|
||||||
|
/\.replace\(\/<meta name="classification" content="\.\*\?" \\\/>\/,\s*`<meta name="classification"/
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
147
tests/gulf-fdi-data.test.mjs
Normal file
147
tests/gulf-fdi-data.test.mjs
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
import { describe, it } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { dirname, resolve } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import * as ts from 'typescript';
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
function loadGulfInvestments() {
|
||||||
|
const sourcePath = resolve(__dirname, '../src/config/gulf-fdi.ts');
|
||||||
|
const source = readFileSync(sourcePath, 'utf-8');
|
||||||
|
const transpiled = ts.transpileModule(source, {
|
||||||
|
compilerOptions: {
|
||||||
|
module: ts.ModuleKind.CommonJS,
|
||||||
|
target: ts.ScriptTarget.ES2020,
|
||||||
|
},
|
||||||
|
fileName: sourcePath,
|
||||||
|
});
|
||||||
|
|
||||||
|
const module = { exports: {} };
|
||||||
|
const evaluator = new Function('exports', 'module', transpiled.outputText);
|
||||||
|
evaluator(module.exports, module);
|
||||||
|
return module.exports.GULF_INVESTMENTS;
|
||||||
|
}
|
||||||
|
|
||||||
|
const GULF_INVESTMENTS = loadGulfInvestments();
|
||||||
|
|
||||||
|
const VALID_COUNTRIES = new Set(['SA', 'UAE']);
|
||||||
|
const VALID_SECTORS = new Set([
|
||||||
|
'ports',
|
||||||
|
'pipelines',
|
||||||
|
'energy',
|
||||||
|
'datacenters',
|
||||||
|
'airports',
|
||||||
|
'railways',
|
||||||
|
'telecoms',
|
||||||
|
'water',
|
||||||
|
'logistics',
|
||||||
|
'mining',
|
||||||
|
'real-estate',
|
||||||
|
'manufacturing',
|
||||||
|
]);
|
||||||
|
const VALID_STATUSES = new Set([
|
||||||
|
'operational',
|
||||||
|
'under-construction',
|
||||||
|
'announced',
|
||||||
|
'rumoured',
|
||||||
|
'cancelled',
|
||||||
|
'divested',
|
||||||
|
]);
|
||||||
|
|
||||||
|
describe('gulf-fdi dataset integrity', () => {
|
||||||
|
it('contains records', () => {
|
||||||
|
assert.ok(Array.isArray(GULF_INVESTMENTS));
|
||||||
|
assert.ok(GULF_INVESTMENTS.length > 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has unique IDs', () => {
|
||||||
|
const ids = GULF_INVESTMENTS.map((investment) => investment.id);
|
||||||
|
const uniqueCount = new Set(ids).size;
|
||||||
|
assert.equal(uniqueCount, ids.length, 'Expected all gulf-fdi IDs to be unique');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses valid enum-like values', () => {
|
||||||
|
for (const investment of GULF_INVESTMENTS) {
|
||||||
|
assert.ok(
|
||||||
|
VALID_COUNTRIES.has(investment.investingCountry),
|
||||||
|
`Invalid investingCountry: ${investment.investingCountry} (${investment.id})`
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
VALID_SECTORS.has(investment.sector),
|
||||||
|
`Invalid sector: ${investment.sector} (${investment.id})`
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
VALID_STATUSES.has(investment.status),
|
||||||
|
`Invalid status: ${investment.status} (${investment.id})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps latitude/longitude in valid ranges', () => {
|
||||||
|
for (const investment of GULF_INVESTMENTS) {
|
||||||
|
assert.ok(
|
||||||
|
Number.isFinite(investment.lat) && investment.lat >= -90 && investment.lat <= 90,
|
||||||
|
`Invalid lat for ${investment.id}: ${investment.lat}`
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
Number.isFinite(investment.lon) && investment.lon >= -180 && investment.lon <= 180,
|
||||||
|
`Invalid lon for ${investment.id}: ${investment.lon}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps optional numeric fields in sane bounds', () => {
|
||||||
|
for (const investment of GULF_INVESTMENTS) {
|
||||||
|
if (investment.investmentUSD != null) {
|
||||||
|
assert.ok(
|
||||||
|
Number.isFinite(investment.investmentUSD) && investment.investmentUSD > 0,
|
||||||
|
`Invalid investmentUSD for ${investment.id}: ${investment.investmentUSD}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (investment.stakePercent != null) {
|
||||||
|
assert.ok(
|
||||||
|
Number.isFinite(investment.stakePercent)
|
||||||
|
&& investment.stakePercent >= 0
|
||||||
|
&& investment.stakePercent <= 100,
|
||||||
|
`Invalid stakePercent for ${investment.id}: ${investment.stakePercent}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validates year and URL fields when present', () => {
|
||||||
|
for (const investment of GULF_INVESTMENTS) {
|
||||||
|
if (investment.yearAnnounced != null) {
|
||||||
|
assert.ok(
|
||||||
|
Number.isInteger(investment.yearAnnounced)
|
||||||
|
&& investment.yearAnnounced >= 1990
|
||||||
|
&& investment.yearAnnounced <= 2100,
|
||||||
|
`Invalid yearAnnounced for ${investment.id}: ${investment.yearAnnounced}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (investment.yearOperational != null) {
|
||||||
|
assert.ok(
|
||||||
|
Number.isInteger(investment.yearOperational)
|
||||||
|
&& investment.yearOperational >= 1990
|
||||||
|
&& investment.yearOperational <= 2100,
|
||||||
|
`Invalid yearOperational for ${investment.id}: ${investment.yearOperational}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (investment.yearAnnounced != null && investment.yearOperational != null) {
|
||||||
|
assert.ok(
|
||||||
|
investment.yearOperational >= investment.yearAnnounced,
|
||||||
|
`yearOperational before yearAnnounced for ${investment.id}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (investment.sourceUrl) {
|
||||||
|
assert.match(
|
||||||
|
investment.sourceUrl,
|
||||||
|
/^https?:\/\//,
|
||||||
|
`sourceUrl must be absolute for ${investment.id}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,6 +3,8 @@ import { VitePWA } from 'vite-plugin-pwa';
|
|||||||
import { resolve } from 'path';
|
import { resolve } from 'path';
|
||||||
import pkg from './package.json';
|
import pkg from './package.json';
|
||||||
|
|
||||||
|
const isE2E = process.env.VITE_E2E === '1';
|
||||||
|
|
||||||
const VARIANT_META: Record<string, {
|
const VARIANT_META: Record<string, {
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
@@ -304,7 +306,15 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
port: 3000,
|
port: 3000,
|
||||||
open: true,
|
open: !isE2E,
|
||||||
|
hmr: isE2E ? false : undefined,
|
||||||
|
watch: {
|
||||||
|
ignored: [
|
||||||
|
'**/test-results/**',
|
||||||
|
'**/playwright-report/**',
|
||||||
|
'**/.playwright-mcp/**',
|
||||||
|
],
|
||||||
|
},
|
||||||
proxy: {
|
proxy: {
|
||||||
// Yahoo Finance API
|
// Yahoo Finance API
|
||||||
'/api/yahoo': {
|
'/api/yahoo': {
|
||||||
|
|||||||
Reference in New Issue
Block a user