mirror of
https://github.com/tiennm99/vngeoguessr.git
synced 2026-10-18 02:15:08 +00:00
A timeout or 5xx from Mapillary no longer moves the day to another panorama; only a missing image does, and the pick is written with SET NX so instances that draw differently still serve one. /api/daily maps a dry pool to 404 and an upstream failure to 502. /api/new-game always mints a fresh session id instead of reusing the client's, which let a new round be overwritten mid-guess; /api/guess rejects ids the server could not have minted. The route reuses the history it already read, runs round statistics alongside the board writes, and drops an unreachable error branch. One cookie parser serves the player id and the debug key; the leaderboard fan-out takes points, not a function, and no longer rewraps errors. Tests share one Mapillary stub, the leaderboard stub contract compares a real scored row, and scanKeys and SET NX are covered.
169 lines
6.1 KiB
JavaScript
169 lines
6.1 KiB
JavaScript
// Browser-boundary stubs shared by the E2E specs.
|
|
//
|
|
// Everything the app fetches -- /api/* and the panorama image -- is answered
|
|
// here with page.route, so the requests never reach the dev server's handlers
|
|
// and the suite needs no Redis, Neon, Mapillary, or .env. The stub payloads
|
|
// mirror the real route response shapes; a contract change should be made in
|
|
// the route tests first, then reflected here.
|
|
|
|
import { readFileSync } from 'node:fs';
|
|
|
|
// Resolved from the project root: Playwright runs specs with the repo as cwd,
|
|
// and import.meta is unavailable here because the specs compile as CJS.
|
|
const PANO_FIXTURE = 'tests/e2e/fixtures/pano.png';
|
|
|
|
// The image URL the new-game stub hands out; the image route below serves the
|
|
// local fixture for it, so nothing leaves the machine.
|
|
export const PANO_IMAGE_URL = 'https://pano.invalid/e2e-round.png';
|
|
|
|
export const USERNAME_STORAGE_KEY = 'vngeoguessr_username';
|
|
|
|
export const HINT_STORAGE_KEY = 'vngeoguessr_hint_seen';
|
|
|
|
/** /api/new-game response for one TPHCM round. */
|
|
export function newGameResponse(sessionId, round) {
|
|
return {
|
|
success: true,
|
|
sessionId,
|
|
region: {
|
|
code: 'TPHCM',
|
|
name: 'Hồ Chí Minh',
|
|
path: ['Vietnam', 'Hồ Chí Minh'],
|
|
level: 'province',
|
|
},
|
|
// A distinct URL per round, as in production (the session id is NOT the
|
|
// distinguisher there -- the route reuses it across rounds): a fixed URL
|
|
// would let a broken next-round image swap pass unnoticed.
|
|
imageData: { url: `${PANO_IMAGE_URL}?round=${round}`, isPano: true },
|
|
};
|
|
}
|
|
|
|
/** /api/guess response: a 123m round on a TPHCM pick, revealed as Quận 7. */
|
|
export function guessResponse(username) {
|
|
// `points` is what this round added at that level; `score` is the
|
|
// accumulated total. Values mirror the real route: 123m is 3 points on the
|
|
// one ladder, credited identically at all three levels.
|
|
const scoreLevel = (code, name, rank, points) =>
|
|
({ code, name, username, points, score: 3, rank });
|
|
const distanceLevel = (code, name, rank) => ({ code, name, username, distance: 123, rank });
|
|
return {
|
|
success: true,
|
|
gameResult: {
|
|
distance: 123,
|
|
score: 3,
|
|
levels: [
|
|
scoreLevel('TPHCM-Q7', 'Quận 7', 1, 3),
|
|
scoreLevel('TPHCM', 'Hồ Chí Minh', 2, 3),
|
|
scoreLevel('VN', 'Vietnam', 5, 3),
|
|
],
|
|
distanceLevels: [
|
|
distanceLevel('TPHCM-Q7', 'Quận 7', 1),
|
|
distanceLevel('TPHCM', 'Hồ Chí Minh', 3),
|
|
distanceLevel('VN', 'Vietnam', 9),
|
|
],
|
|
partial: false,
|
|
region: {
|
|
code: 'TPHCM-Q7',
|
|
name: 'Quận 7',
|
|
path: ['Vietnam', 'Hồ Chí Minh', 'Quận 7'],
|
|
level: 'district',
|
|
},
|
|
guessedRegion: {
|
|
code: 'TPHCM-Q7',
|
|
name: 'Quận 7',
|
|
path: ['Vietnam', 'Hồ Chí Minh', 'Quận 7'],
|
|
level: 'district',
|
|
},
|
|
hit: 'district',
|
|
exactLocation: { lat: 10.7411, lng: 106.7218 },
|
|
},
|
|
};
|
|
}
|
|
|
|
/** /api/daily response: today's challenge, day #1, on the same TPHCM image. */
|
|
export function dailyResponse(sessionId) {
|
|
return {
|
|
success: true,
|
|
sessionId,
|
|
day: '2026-09-20',
|
|
number: 1,
|
|
region: { code: 'VN', name: 'Vietnam', path: ['Vietnam'], level: 'country' },
|
|
imageData: { url: `${PANO_IMAGE_URL}?daily=1`, isPano: true },
|
|
};
|
|
}
|
|
|
|
/** /api/leaderboard response with recognisable rows. */
|
|
export function leaderboardResponse() {
|
|
return {
|
|
success: true,
|
|
leaderboard: [
|
|
{ username: 'top-player', score: 42, rank: 1 },
|
|
{ username: 'runner-up', score: 17, rank: 2 },
|
|
],
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Intercept every request the game makes. Call before page.goto.
|
|
* @param {import('@playwright/test').Page} page Playwright page.
|
|
* @param {string} username Player name echoed into guess responses.
|
|
*/
|
|
export async function stubGameApis(page, username) {
|
|
let round = 0;
|
|
await page.route('**/api/new-game**', async (route) => {
|
|
round += 1;
|
|
// A fresh id every round, as the real route mints one.
|
|
await route.fulfill({ json: newGameResponse(`e2e-session-${round}`, round) });
|
|
});
|
|
await page.route('**/api/guess', async (route) => {
|
|
await route.fulfill({ json: guessResponse(username) });
|
|
});
|
|
await page.route('**/api/daily', async (route) => {
|
|
await route.fulfill({ json: dailyResponse('e2e-daily-session') });
|
|
});
|
|
await page.route('**/api/skip', async (route) => {
|
|
await route.fulfill({ json: { success: true } });
|
|
});
|
|
await page.route('**/api/leaderboard**', async (route) => {
|
|
await route.fulfill({ json: leaderboardResponse() });
|
|
});
|
|
await page.route(`${PANO_IMAGE_URL}*`, async (route) => {
|
|
await route.fulfill({ contentType: 'image/png', body: readFileSync(PANO_FIXTURE) });
|
|
});
|
|
// Leaflet's base tiles are the one request the app makes to a third party.
|
|
// Stub them too, or every run hammers openstreetmap.org and the suite is not
|
|
// actually offline. Both providers from src/lib/map-tiles.js are stubbed:
|
|
// the dev server loads .env, so a filled-in NEXT_PUBLIC_GEOAPIFY_KEY would
|
|
// otherwise send every run to Geoapify and spend metered free-tier credits.
|
|
await page.route('**/tile.openstreetmap.org/**', async (route) => {
|
|
await route.fulfill({ contentType: 'image/png', body: readFileSync(PANO_FIXTURE) });
|
|
});
|
|
await page.route('**/maps.geoapify.com/**', async (route) => {
|
|
await route.fulfill({ contentType: 'image/png', body: readFileSync(PANO_FIXTURE) });
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Preload a username so the modal stays closed and guesses carry a known name.
|
|
* @param {import('@playwright/test').Page} page Playwright page.
|
|
* @param {string} username Name to store.
|
|
*/
|
|
export async function seedUsername(page, username) {
|
|
await page.addInitScript(
|
|
([key, value]) => window.localStorage.setItem(key, value),
|
|
[USERNAME_STORAGE_KEY, username]
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Mark the one-time how-to-play hint as already seen, so specs that are not
|
|
* about onboarding never race its banner for clicks.
|
|
* @param {import('@playwright/test').Page} page Playwright page.
|
|
*/
|
|
export async function seedHintSeen(page) {
|
|
await page.addInitScript(
|
|
(key) => window.localStorage.setItem(key, '1'),
|
|
HINT_STORAGE_KEY
|
|
);
|
|
}
|