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;
|
||||
});
|
||||
});
|
||||
|
||||
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',
|
||||
'central-banks-layer',
|
||||
'commodity-hubs-layer',
|
||||
'gulf-investments-layer',
|
||||
];
|
||||
|
||||
const waitForHarnessReady = async (
|
||||
@@ -164,6 +165,8 @@ const prepareVisualScenario = async (
|
||||
};
|
||||
|
||||
test.describe('DeckGL map harness', () => {
|
||||
test.describe.configure({ retries: 1 });
|
||||
|
||||
test('serves requested runtime variant for this test run', async ({ 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 }) => {
|
||||
await waitForHarnessReady(page);
|
||||
|
||||
@@ -342,44 +371,16 @@ test.describe('DeckGL map harness', () => {
|
||||
|
||||
await page.evaluate(() => {
|
||||
const w = window as HarnessWindow;
|
||||
w.__mapHarness?.seedAllDynamicData();
|
||||
w.__mapHarness?.setHotspotActivityScenario('none');
|
||||
w.__mapHarness?.setPulseProtestsScenario('none');
|
||||
w.__mapHarness?.setNewsPulseScenario('none');
|
||||
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');
|
||||
});
|
||||
|
||||
await page.waitForTimeout(800);
|
||||
await page.waitForTimeout(600);
|
||||
|
||||
const gdeltPulseRunning = await page.evaluate(() => {
|
||||
const w = window as HarnessWindow;
|
||||
return w.__mapHarness?.isPulseAnimationRunning() ?? false;
|
||||
@@ -397,8 +398,25 @@ test.describe('DeckGL map harness', () => {
|
||||
const w = window as HarnessWindow;
|
||||
return w.__mapHarness?.isPulseAnimationRunning() ?? false;
|
||||
});
|
||||
}, { timeout: 10000 })
|
||||
}, { timeout: 15000 })
|
||||
.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 }) => {
|
||||
|
||||
@@ -152,6 +152,79 @@ test.describe('desktop runtime routing guardrails', () => {
|
||||
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 }) => {
|
||||
await page.goto('/tests/runtime-harness.html');
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"test:e2e:finance": "VITE_VARIANT=finance playwright test",
|
||||
"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: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: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\"",
|
||||
|
||||
@@ -32,7 +32,7 @@ export default defineConfig({
|
||||
],
|
||||
snapshotPathTemplate: '{testDir}/{testFileName}-snapshots/{arg}{ext}',
|
||||
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',
|
||||
reuseExistingServer: false,
|
||||
timeout: 120000,
|
||||
|
||||
@@ -25,6 +25,7 @@ import { analyzeFlightsForSurge, surgeAlertToSignal, detectForeignMilitaryPresen
|
||||
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 { dataFreshness, type DataSourceId } from '@/services/data-freshness';
|
||||
import { focusInvestmentOnMap } from '@/services/investments-focus';
|
||||
import { fetchConflictEvents } from '@/services/conflicts';
|
||||
import { fetchUcdpClassifications } from '@/services/ucdp';
|
||||
import { fetchHapiSummary } from '@/services/hapi';
|
||||
@@ -2149,9 +2150,7 @@ export class App {
|
||||
// GCC Investments Panel (finance variant)
|
||||
if (SITE_VARIANT === 'finance') {
|
||||
const investmentsPanel = new InvestmentsPanel((inv) => {
|
||||
this.map?.enableLayer('gulfInvestments');
|
||||
this.mapLayers.gulfInvestments = true;
|
||||
this.map?.setCenter(inv.lat, inv.lon, 6);
|
||||
focusInvestmentOnMap(this.map, this.mapLayers, inv.lat, inv.lon);
|
||||
});
|
||||
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 { loadDesktopSecrets } from '@/services/runtime-config';
|
||||
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)
|
||||
window.addEventListener('vite:preloadError', () => {
|
||||
if (!sessionStorage.getItem(chunkReloadStorageKey)) {
|
||||
sessionStorage.setItem(chunkReloadStorageKey, '1');
|
||||
window.location.reload();
|
||||
}
|
||||
});
|
||||
// Auto-reload on stale chunk 404s after deployment (Vite fires this for modulepreload failures).
|
||||
const chunkReloadStorageKey = installChunkReloadGuard(__APP_VERSION__);
|
||||
|
||||
// Initialize Vercel Analytics
|
||||
inject();
|
||||
@@ -41,7 +35,7 @@ app
|
||||
.init()
|
||||
.then(() => {
|
||||
// Clear the one-shot guard after a successful boot so future stale-chunk incidents can recover.
|
||||
sessionStorage.removeItem(chunkReloadStorageKey);
|
||||
clearChunkReloadGuard(chunkReloadStorageKey);
|
||||
})
|
||||
.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 pkg from './package.json';
|
||||
|
||||
const isE2E = process.env.VITE_E2E === '1';
|
||||
|
||||
const VARIANT_META: Record<string, {
|
||||
title: string;
|
||||
description: string;
|
||||
@@ -304,7 +306,15 @@ export default defineConfig({
|
||||
},
|
||||
server: {
|
||||
port: 3000,
|
||||
open: true,
|
||||
open: !isE2E,
|
||||
hmr: isE2E ? false : undefined,
|
||||
watch: {
|
||||
ignored: [
|
||||
'**/test-results/**',
|
||||
'**/playwright-report/**',
|
||||
'**/.playwright-mcp/**',
|
||||
],
|
||||
},
|
||||
proxy: {
|
||||
// Yahoo Finance API
|
||||
'/api/yahoo': {
|
||||
|
||||
Reference in New Issue
Block a user