Some checks failed
Test and deploy staging / staging (push) Failing after 2m50s
1000 lines
37 KiB
TypeScript
1000 lines
37 KiB
TypeScript
import { test, expect, type APIRequestContext } from '@playwright/test';
|
|
import postgres from 'postgres';
|
|
|
|
test.skip(
|
|
!process.env.TRACKER_E2E,
|
|
'Opt-in: npm run test:tracker uses the migrated local development database.'
|
|
);
|
|
const origin = 'http://127.0.0.1:4174';
|
|
const users: string[] = [];
|
|
let sql: ReturnType<typeof postgres>;
|
|
test.beforeAll(() => {
|
|
if (!process.env.DB_URL) throw new Error('DB_URL required');
|
|
sql = postgres(process.env.DB_URL, { max: 1 });
|
|
});
|
|
test.afterAll(async () => {
|
|
if (!sql) return;
|
|
for (const id of users) await sql`delete from "user" where id=${id}`;
|
|
await sql.end();
|
|
});
|
|
async function register(request: APIRequestContext, attempt = 0): Promise<string> {
|
|
const response = await request.post('/api/auth/sign-up/email', {
|
|
headers: { Origin: origin },
|
|
data: {
|
|
name: 'Tracker Tester',
|
|
email: `tracker-${crypto.randomUUID()}@example.com`,
|
|
password: 'Tracker-test-password-2026!'
|
|
}
|
|
});
|
|
if (response.status() === 429 && attempt < 2) {
|
|
await new Promise((resolve) => setTimeout(resolve, 11000));
|
|
return register(request, attempt + 1);
|
|
}
|
|
expect(response.ok(), await response.text()).toBe(true);
|
|
const body = await response.json();
|
|
users.push(body.user.id);
|
|
return body.user.id as string;
|
|
}
|
|
async function action(
|
|
request: APIRequestContext,
|
|
path: string,
|
|
values: Record<string, string | string[]>
|
|
) {
|
|
const body = new URLSearchParams();
|
|
for (const [k, v] of Object.entries(values))
|
|
for (const value of Array.isArray(v) ? v : [v]) body.append(k, value);
|
|
const response = await request.post(path, {
|
|
headers: {
|
|
Origin: origin,
|
|
Accept: 'application/json',
|
|
'x-sveltekit-action': 'true',
|
|
'content-type': 'application/x-www-form-urlencoded'
|
|
},
|
|
data: body.toString()
|
|
});
|
|
const result = await response.json();
|
|
return { status: result.status ?? response.status(), type: result.type, result };
|
|
}
|
|
|
|
test('Platinum route, regional dex, catch journal, duplicate policy, team slots and ownership', async ({
|
|
page,
|
|
playwright
|
|
}) => {
|
|
const id = await register(page.request);
|
|
const [{ id: gameId }] = await sql`select id from games where slug='platinum'`;
|
|
const [{ id: pokemonId }] = await sql`select id from pokemon where number=387`;
|
|
await page.goto('/projects/game-tracker/pokemon?game=platinum');
|
|
await expect(page.getByRole('heading', { name: 'Pokémon journal' })).toBeVisible();
|
|
await expect(page.getByRole('button', { name: 'set rules', exact: true })).toBeEnabled({
|
|
timeout: 15000
|
|
});
|
|
await expect(page.getByRole('button', { name: /Turtwig Grass/ })).toBeVisible();
|
|
await expect(page.getByRole('button', { name: /Nidoran/ })).toHaveCount(0);
|
|
await page.getByRole('button', { name: 'set rules', exact: true }).click();
|
|
await expect(page.getByRole('dialog', { name: 'Set rules' })).toBeVisible();
|
|
await page.keyboard.press('Escape');
|
|
await expect(page.getByRole('dialog')).toHaveCount(0);
|
|
await page.getByRole('tab', { name: 'Pokédex', exact: true }).click();
|
|
await expect(page.getByLabel('Pokédex order')).toHaveValue('extended-sinnoh');
|
|
await expect(page.locator('article').first()).toContainText('Turtwig');
|
|
await page.getByLabel('Pokédex order').selectOption('national');
|
|
await expect(page.locator('article').first()).toContainText('Bulbasaur');
|
|
const catchValues = {
|
|
gameId: String(gameId),
|
|
pokemonId: String(pokemonId),
|
|
nickname: 'Sprout',
|
|
level: '5',
|
|
moves: ['Tackle', 'Withdraw'],
|
|
stage: 'story',
|
|
obtained: 'starter',
|
|
partySlot: '1',
|
|
caughtOn: '2026-09-25',
|
|
location: 'Route 201'
|
|
};
|
|
expect(
|
|
(await action(page.request, '/projects/game-tracker/pokemon?/saveCatch', catchValues)).status
|
|
).toBe(200);
|
|
const [first] = await sql`select * from pokemon_catches where user_id=${id}`;
|
|
expect(
|
|
(
|
|
await action(page.request, '/projects/game-tracker/pokemon?/saveCatch', {
|
|
...catchValues,
|
|
nickname: 'Second Sprout',
|
|
partySlot: '2'
|
|
})
|
|
).status
|
|
).toBe(200);
|
|
const [second] = await sql`select * from pokemon_catches where user_id=${id} and id<>${first.id}`;
|
|
await page.reload();
|
|
await page.getByRole('tab', { name: /My PC/ }).click();
|
|
await expect(page.getByRole('tabpanel').getByRole('button', { name: /#387/ })).toContainText(
|
|
'Sprout'
|
|
);
|
|
await expect(page.getByRole('tabpanel').getByRole('button', { name: /#387/ })).not.toContainText(
|
|
'Second Sprout'
|
|
);
|
|
await page.getByRole('tabpanel').getByRole('button', { name: /#387/ }).click();
|
|
await expect(page.getByRole('dialog')).toContainText('Meet Sprout');
|
|
await page.getByLabel('Level', { exact: true }).fill('12');
|
|
await page.getByRole('button', { name: 'Save Pokémon', exact: true }).click();
|
|
await expect(page.getByRole('dialog')).toHaveCount(0);
|
|
expect((await sql`select level from pokemon_catches where id=${first.id}`)[0].level).toBe(12);
|
|
expect(
|
|
(
|
|
await action(page.request, '/projects/game-tracker/pokemon?/saveCatch', {
|
|
...catchValues,
|
|
id: String(second.id),
|
|
nickname: 'Second Sprout',
|
|
partySlot: '1'
|
|
})
|
|
).status
|
|
).toBe(200);
|
|
expect(
|
|
(await sql`select party_slot from pokemon_catches where id=${first.id}`)[0].party_slot
|
|
).toBeNull();
|
|
expect(
|
|
(
|
|
await action(page.request, '/projects/game-tracker/pokemon?/saveCatch', {
|
|
...catchValues,
|
|
moves: ['a', 'b', 'c', 'd', 'e']
|
|
})
|
|
).status
|
|
).toBe(400);
|
|
const other = await playwright.request.newContext({ baseURL: origin });
|
|
await register(other);
|
|
expect(
|
|
(
|
|
await action(other, '/projects/game-tracker/pokemon?/saveCatch', {
|
|
...catchValues,
|
|
id: String(first.id)
|
|
})
|
|
).status
|
|
).toBe(404);
|
|
expect(
|
|
(await action(other, '/projects/game-tracker/pokemon?/deleteCatch', { id: String(first.id) }))
|
|
.status
|
|
).toBe(404);
|
|
await other.dispose();
|
|
expect(
|
|
(
|
|
await action(page.request, '/projects/game-tracker/pokemon?/saveJourney', {
|
|
gameId: String(gameId),
|
|
stage: 'postgame',
|
|
location: 'stark-mountain'
|
|
})
|
|
).status
|
|
).toBe(200);
|
|
expect(
|
|
(
|
|
await action(page.request, '/projects/game-tracker/pokemon?/saveMilestone', {
|
|
gameId: String(gameId),
|
|
stage: 'story',
|
|
key: 'league:champion-cynthia',
|
|
title: 'Champion · Cynthia',
|
|
category: 'league',
|
|
done: 'on'
|
|
})
|
|
).status
|
|
).toBe(200);
|
|
await page.reload();
|
|
await expect(page.getByLabel('My playthrough')).toHaveValue('postgame');
|
|
await expect(page.getByLabel('Encounter location')).toHaveValue('stark-mountain');
|
|
await page.screenshot({ path: '/tmp/tracker-pokemon-saved.png', fullPage: true });
|
|
const [entry] =
|
|
await sql`select id from pokemon_game_entries where game_id=${gameId} and pokemon_id=${pokemonId}`;
|
|
expect(
|
|
(
|
|
await action(page.request, '/projects/game-tracker/pokemon?/togglePokemonCaught', {
|
|
entryId: String(entry.id)
|
|
})
|
|
).status
|
|
).toBe(400);
|
|
for (const row of [first, second])
|
|
expect(
|
|
(
|
|
await action(page.request, '/projects/game-tracker/pokemon?/deleteCatch', {
|
|
id: String(row.id)
|
|
})
|
|
).status
|
|
).toBe(200);
|
|
expect(
|
|
(
|
|
await sql`select * from user_entries where user_id=${id} and pokemon_game_entry_id=${entry.id}`
|
|
).length
|
|
).toBe(1);
|
|
expect(
|
|
(
|
|
await action(page.request, '/projects/game-tracker/pokemon?/togglePokemonCaught', {
|
|
entryId: String(entry.id)
|
|
})
|
|
).status
|
|
).toBe(200);
|
|
});
|
|
|
|
test('weapon/player ranks, challenge progress and osu goals/scores/training persist independently', async ({
|
|
page,
|
|
playwright
|
|
}) => {
|
|
const id = await register(page.request);
|
|
const [weapon] =
|
|
await sql`select i.id, i.name from tracker_items i join tracker_games g on i.game_id=g.id where g.slug='black-ops-3' and i.kind='weapon' order by i.id limit 1`;
|
|
const [challenge] =
|
|
await sql`select c.id,c.target from tracker_challenges c join tracker_games g on c.game_id=g.id where g.slug='black-ops-3' and c.target>1 limit 1`;
|
|
const cod = '/projects/game-tracker/call-of-duty/black-ops-3';
|
|
await page.goto(cod);
|
|
await page.getByRole('button', { name: 'Update my rank' }).click();
|
|
await page.getByLabel('Current level', { exact: true }).fill('42');
|
|
await page.getByLabel('Prestige', { exact: true }).fill('3');
|
|
await page.getByRole('button', { name: 'Save rank' }).click();
|
|
await expect(page.getByRole('dialog')).toHaveCount(0);
|
|
await expect(page.getByLabel('Player level and prestige')).toContainText('LEVEL42');
|
|
await expect(page.getByLabel('Player level and prestige')).toContainText('PRESTIGE3');
|
|
expect(
|
|
(
|
|
await action(page.request, cod + '?/saveRank', {
|
|
itemId: String(weapon.id),
|
|
level: '17',
|
|
prestige: '2'
|
|
})
|
|
).status
|
|
).toBe(200);
|
|
expect(
|
|
(
|
|
await action(page.request, cod + '?/saveChallenge', {
|
|
challengeId: String(challenge.id),
|
|
current: String(challenge.target)
|
|
})
|
|
).status
|
|
).toBe(200);
|
|
await page.reload();
|
|
const weaponCard = page.getByRole('article', { name: weapon.name, exact: true });
|
|
await expect(
|
|
weaponCard.getByRole('button', { name: `Edit ${weapon.name} level and prestige` })
|
|
).toContainText('17');
|
|
await weaponCard.getByRole('button', { name: `${weapon.name} camos and milestones` }).click();
|
|
await expect(page.getByRole('dialog')).toContainText('Max level reached');
|
|
await page.keyboard.press('Escape');
|
|
await page.getByRole('tab', { name: 'Challenges', exact: true }).click();
|
|
await expect(page.locator('article')).toHaveCount(36);
|
|
const goal = {
|
|
kind: 'goal',
|
|
title: 'First 200 BPM FC',
|
|
mode: 'osu',
|
|
goal: 'fc',
|
|
skill: 'stamina',
|
|
playedOn: '2026-09-25',
|
|
rate: '1.00',
|
|
mods: 'NM',
|
|
beatmapUrl: 'https://osu.ppy.sh/beatmaps/123'
|
|
};
|
|
expect((await action(page.request, '/projects/game-tracker/osu?/save', goal)).status).toBe(200);
|
|
expect(
|
|
(
|
|
await action(page.request, '/projects/game-tracker/osu?/save', {
|
|
...goal,
|
|
kind: 'score',
|
|
title: 'My best play',
|
|
accuracy: '99.25',
|
|
combo: '1234',
|
|
misses: '0',
|
|
pp: '250.3',
|
|
scoreUrl: 'https://osu.ppy.sh/scores/123'
|
|
})
|
|
).status
|
|
).toBe(200);
|
|
expect(
|
|
(
|
|
await action(page.request, '/projects/game-tracker/osu?/save', {
|
|
...goal,
|
|
kind: 'training',
|
|
title: 'Stream practice',
|
|
bpm: '180',
|
|
duration: '60',
|
|
accuracy: '98.5'
|
|
})
|
|
).status
|
|
).toBe(200);
|
|
expect(
|
|
(
|
|
await action(page.request, '/projects/game-tracker/osu?/save', {
|
|
...goal,
|
|
kind: 'training',
|
|
title: 'Faster streams',
|
|
bpm: '140',
|
|
duration: '45',
|
|
rate: '1.50',
|
|
mods: 'DT',
|
|
accuracy: '97.5'
|
|
})
|
|
).status
|
|
).toBe(200);
|
|
expect(
|
|
(
|
|
await action(page.request, '/projects/game-tracker/osu?/save', {
|
|
...goal,
|
|
beatmapUrl: 'https://osu.ppy.sh.evil.test/beatmaps/123'
|
|
})
|
|
).status
|
|
).toBe(400);
|
|
expect(
|
|
(
|
|
await action(page.request, '/projects/game-tracker/osu?/save', {
|
|
...goal,
|
|
kind: 'training',
|
|
duration: '0',
|
|
bpm: '180'
|
|
})
|
|
).status
|
|
).toBe(400);
|
|
await page.goto('/projects/game-tracker/osu');
|
|
await expect(page.getByRole('heading', { name: 'First 200 BPM FC' })).toBeVisible();
|
|
await page.getByRole('button', { name: 'Achieved!', exact: true }).click();
|
|
await expect(page.getByText('✓ achieved')).toBeVisible();
|
|
await page.getByRole('tab', { name: 'Training log' }).click();
|
|
await expect(page.getByText('210 effective BPM')).toBeVisible();
|
|
await expect(page.getByRole('img', { name: /Last 2 qualifying/ })).toBeVisible();
|
|
await page.screenshot({ path: '/tmp/tracker-osu-saved.png', fullPage: true });
|
|
const [row] = await sql`select id from osu_entries where user_id=${id} limit 1`;
|
|
const other = await playwright.request.newContext({ baseURL: origin });
|
|
await register(other);
|
|
expect(
|
|
(await action(other, '/projects/game-tracker/osu?/save', { ...goal, id: String(row.id) }))
|
|
.status
|
|
).toBe(404);
|
|
expect(
|
|
(await action(other, '/projects/game-tracker/osu?/delete', { id: String(row.id) })).status
|
|
).toBe(404);
|
|
await other.dispose();
|
|
});
|
|
|
|
test('shared dialogs have focus, Escape dismissal and mobile layouts without overflow', async ({
|
|
page
|
|
}) => {
|
|
const id = await register(page.request);
|
|
await sql`update "user" set role='admin' where id=${id}`;
|
|
for (const [path, label, title] of [
|
|
['/blog', 'new article', 'new article'],
|
|
['/lists', 'new list', 'new list']
|
|
]) {
|
|
await page.goto(path);
|
|
await page.getByRole('button', { name: label, exact: true }).click();
|
|
const dialog = page.getByRole('dialog', { name: title });
|
|
await expect(dialog).toBeVisible();
|
|
await expect(dialog.getByRole('textbox').first()).toBeFocused();
|
|
await page.keyboard.press('Escape');
|
|
await expect(dialog).toHaveCount(0);
|
|
await expect(page.getByRole('button', { name: label, exact: true })).toBeFocused();
|
|
}
|
|
await page.goto('/projects/money-tracker');
|
|
await page.getByRole('heading', { name: 'Accounts', exact: true }).waitFor();
|
|
await page.locator('#accounts').getByRole('button', { name: 'Add', exact: true }).click();
|
|
const moneyDialog = page.getByRole('dialog');
|
|
await expect(moneyDialog).toBeVisible();
|
|
await moneyDialog.getByLabel('Account name', { exact: true }).fill('Tracker integration account');
|
|
await moneyDialog.getByLabel(/^Opening balance/).fill('123.45');
|
|
await moneyDialog.getByRole('button', { name: 'Save', exact: true }).click();
|
|
await expect(moneyDialog).toHaveCount(0);
|
|
expect(
|
|
(await sql`select opening_balance_cents from money_accounts where user_id=${id}`)[0]
|
|
.opening_balance_cents
|
|
).toBe(12345);
|
|
await page.setViewportSize({ width: 390, height: 844 });
|
|
for (const path of [
|
|
'/projects/game-tracker',
|
|
'/projects/game-tracker/pokemon?game=platinum',
|
|
'/projects/game-tracker/osu',
|
|
'/projects/game-tracker/call-of-duty/black-ops-3'
|
|
]) {
|
|
await page.goto(path);
|
|
await expect(page.locator('h1')).not.toHaveText('500');
|
|
expect(
|
|
await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)
|
|
).toBe(true);
|
|
}
|
|
await page.goto('/projects/game-tracker/pokemon?game=platinum');
|
|
await expect(page.getByRole('button', { name: /Turtwig Grass/ })).toBeVisible();
|
|
await page.screenshot({ path: '/tmp/tracker-pokemon-mobile.png', fullPage: true });
|
|
});
|
|
|
|
test('illustrated library uses two Pokémon columns, follows selected games, and picture buttons select one game', async ({
|
|
page
|
|
}) => {
|
|
await register(page.request);
|
|
const games = await sql<{ id: number; slug: string }[]>`select id, slug from games`;
|
|
const selected = ['platinum', 'red', 'white', 'emerald', 'legends-arceus'];
|
|
expect(
|
|
(
|
|
await action(page.request, '/projects/game-tracker/pokemon?/setGames', {
|
|
gameIds: games.filter((g) => selected.includes(g.slug)).map((g) => String(g.id))
|
|
})
|
|
).status
|
|
).toBe(200);
|
|
await page.setViewportSize({ width: 1440, height: 900 });
|
|
await page.goto('/projects/game-tracker');
|
|
await expect(page.locator('.collection-grid .game-row')).toHaveCount(5);
|
|
await expect(page.locator('.collection-grid .game-name')).toHaveText([
|
|
'Red',
|
|
'Emerald',
|
|
'Platinum',
|
|
'White',
|
|
'Legends: Arceus'
|
|
]);
|
|
await expect(page.locator('.cod-rows .game-row')).toHaveCount(7);
|
|
await expect
|
|
.poll(() =>
|
|
page
|
|
.locator('main img')
|
|
.evaluateAll((images) =>
|
|
images.every(
|
|
(i) => (i as HTMLImageElement).complete && (i as HTMLImageElement).naturalWidth > 0
|
|
)
|
|
)
|
|
)
|
|
.toBe(true);
|
|
await page.screenshot({ path: '/tmp/tracker-library-pictures.png', fullPage: true });
|
|
await page.getByRole('link', { name: /Platinum artwork Platinum/ }).click();
|
|
await expect(page.getByRole('button', { name: 'Play Platinum', exact: true })).toHaveAttribute(
|
|
'aria-pressed',
|
|
'true'
|
|
);
|
|
await expect(page.getByRole('combobox', { name: 'Current game' })).toHaveCount(0);
|
|
await expect(page.getByRole('dialog')).toHaveCount(0);
|
|
const map = page.getByRole('img', { name: 'Sinnoh region map from the Pokémon games' });
|
|
await expect(map).toBeVisible();
|
|
await expect
|
|
.poll(() => map.evaluate((i) => (i as HTMLImageElement).naturalWidth))
|
|
.toBeGreaterThan(0);
|
|
await page.getByRole('button', { name: 'Explore Route 201', exact: true }).click();
|
|
await expect(page.getByRole('button', { name: /Turtwig Grass/ })).toBeVisible();
|
|
await map.scrollIntoViewIfNeeded();
|
|
await page.screenshot({ path: '/tmp/tracker-real-map.png' });
|
|
await page.getByRole('button', { name: 'Play White', exact: true }).click();
|
|
await expect(page).toHaveURL(/game=white/);
|
|
await expect(page.getByRole('button', { name: 'Play White', exact: true })).toHaveAttribute(
|
|
'aria-pressed',
|
|
'true'
|
|
);
|
|
await expect(page.getByRole('button', { name: 'Play Platinum', exact: true })).toHaveAttribute(
|
|
'aria-pressed',
|
|
'false'
|
|
);
|
|
// Large collections stay readable in exactly two columns; vertical page scrolling is allowed.
|
|
expect(
|
|
(
|
|
await action(page.request, '/projects/game-tracker/pokemon?/setGames', {
|
|
gameIds: games.map((g) => String(g.id))
|
|
})
|
|
).status
|
|
).toBe(200);
|
|
for (const [width, height] of [
|
|
[1440, 900],
|
|
[1280, 720]
|
|
]) {
|
|
await page.setViewportSize({ width, height });
|
|
await page.goto('/projects/game-tracker');
|
|
await expect(page.locator('.collection-grid .game-row')).toHaveCount(games.length);
|
|
const size = await page.evaluate(() => ({
|
|
height: document.documentElement.scrollHeight,
|
|
width: document.documentElement.scrollWidth,
|
|
viewportHeight: innerHeight,
|
|
viewportWidth: innerWidth
|
|
}));
|
|
expect(
|
|
await page
|
|
.locator('.collection-grid')
|
|
.evaluate((el) => getComputedStyle(el).gridTemplateColumns.split(' ').length)
|
|
).toBe(2);
|
|
expect(size.width).toBeLessThanOrEqual(size.viewportWidth);
|
|
const clipped = await page.locator('.game-row').evaluateAll(
|
|
(rows) =>
|
|
rows.filter((r) => {
|
|
const b = r.getBoundingClientRect();
|
|
return b.right > innerWidth || b.height < 56;
|
|
}).length
|
|
);
|
|
|
|
expect(clipped).toBe(0);
|
|
}
|
|
await page.screenshot({ path: '/tmp/tracker-library-all-games.png', fullPage: true });
|
|
await page.goto('/projects/game-tracker/pokemon?game=platinum');
|
|
await expect(
|
|
page.getByRole('group', { name: 'Choose Pokémon game' }).getByRole('button')
|
|
).toHaveCount(games.length);
|
|
await expect
|
|
.poll(() =>
|
|
page
|
|
.locator('.game-cover-button')
|
|
.evaluateAll(
|
|
(buttons) => new Set(buttons.map((b) => Math.round(b.getBoundingClientRect().top))).size
|
|
)
|
|
)
|
|
.toBe(2);
|
|
await expect(page.getByRole('button', { name: 'Switch game', exact: true })).toHaveCount(0);
|
|
const covers = await page.locator('.game-cover-button').evaluateAll((buttons) =>
|
|
buttons.every((b) => {
|
|
const r = b.getBoundingClientRect();
|
|
return r.left >= 0 && r.right <= innerWidth;
|
|
})
|
|
);
|
|
expect(covers).toBe(true);
|
|
});
|
|
|
|
test('live weapon controls save immediately, infer max level from prestige, and handle concurrent updates', async ({
|
|
page,
|
|
playwright
|
|
}) => {
|
|
const userId = await register(page.request);
|
|
const [weapon] =
|
|
await sql`select i.id,i.name from tracker_items i join tracker_games g on i.game_id=g.id where g.slug='black-ops-3' and i.kind='weapon' order by i.sort_order limit 1`;
|
|
const cod = '/projects/game-tracker/call-of-duty/black-ops-3';
|
|
await page.setViewportSize({ width: 1280, height: 800 });
|
|
await page.goto(cod);
|
|
await page.getByRole('button', { name: `${weapon.name} camos and milestones` }).click();
|
|
const card = page.getByRole('dialog', { name: weapon.name, exact: true });
|
|
const level = card.getByRole('button', { name: `Edit ${weapon.name} level and prestige` });
|
|
await expect(card.getByRole('button', { name: `Level down ${weapon.name}` })).toBeDisabled();
|
|
await card.getByRole('button', { name: `Level up ${weapon.name}` }).click();
|
|
await expect(level).toHaveText(/LEVEL2/);
|
|
await card.getByRole('button', { name: `Level down ${weapon.name}` }).click();
|
|
await expect(level).toHaveText(/LEVEL1/);
|
|
await card.getByRole('button', { name: `Prestige up ${weapon.name}` }).click();
|
|
await expect(card).toContainText('Max level reached');
|
|
await expect(card.getByRole('checkbox', { name: /max level/ })).toHaveCount(0);
|
|
expect(
|
|
(
|
|
await sql`select level,prestige from tracker_weapon_ranks where user_id=${userId} and item_id=${weapon.id}`
|
|
)[0]
|
|
).toMatchObject({ level: 1, prestige: 1 });
|
|
const concurrent = await Promise.all(
|
|
Array.from({ length: 4 }, () =>
|
|
action(page.request, cod + '?/adjustWeaponRank', {
|
|
itemId: String(weapon.id),
|
|
field: 'level',
|
|
delta: '1'
|
|
})
|
|
)
|
|
);
|
|
expect(concurrent.every((r) => r.status === 200)).toBe(true);
|
|
await page.reload();
|
|
await page.getByRole('button', { name: `${weapon.name} camos and milestones` }).click();
|
|
await expect(level).toHaveText(/LEVEL5/);
|
|
await page.route('**/*adjustWeaponRank', (route) => route.abort());
|
|
await card.getByRole('button', { name: `Level up ${weapon.name}` }).click();
|
|
await expect(card.getByRole('status')).toContainText(/fetch|save|network/i);
|
|
await expect(level).toHaveText(/LEVEL5/);
|
|
await page.unroute('**/*adjustWeaponRank');
|
|
await card.getByRole('button', { name: `Level up ${weapon.name}` }).click();
|
|
await expect(level).toHaveText(/LEVEL6/);
|
|
expect(
|
|
(
|
|
await action(page.request, cod + '?/adjustWeaponRank', {
|
|
itemId: String(weapon.id),
|
|
field: 'level',
|
|
delta: '999'
|
|
})
|
|
).status
|
|
).toBe(400);
|
|
expect(
|
|
(
|
|
await action(page.request, '/projects/game-tracker/call-of-duty/wwii?/adjustWeaponRank', {
|
|
itemId: String(weapon.id),
|
|
field: 'level',
|
|
delta: '1'
|
|
})
|
|
).status
|
|
).toBe(404);
|
|
const anonymous = await playwright.request.newContext({ baseURL: origin });
|
|
expect(
|
|
(
|
|
await action(anonymous, cod + '?/adjustWeaponRank', {
|
|
itemId: String(weapon.id),
|
|
field: 'level',
|
|
delta: '1'
|
|
})
|
|
).status
|
|
).toBe(401);
|
|
await anonymous.dispose();
|
|
await page.keyboard.press('Escape');
|
|
const cards = await page
|
|
.locator('.weapon-card')
|
|
.evaluateAll((cards) => cards.map((c) => c.getBoundingClientRect().bottom));
|
|
expect(Math.max(...cards)).toBeLessThanOrEqual(800);
|
|
await page.screenshot({ path: '/tmp/tracker-live-cod.png' });
|
|
});
|
|
|
|
test('crowded Pokémon areas show every species without a scrolling list and allow a catch from the map', async ({
|
|
page
|
|
}) => {
|
|
await register(page.request);
|
|
await page.setViewportSize({ width: 1280, height: 800 });
|
|
await page.goto('/projects/game-tracker/pokemon?game=platinum');
|
|
await expect(page.getByRole('button', { name: /Turtwig Grass/ })).toBeVisible();
|
|
const [{ id: locationId }] =
|
|
await sql`select id from pokemon_locations where slug='sinnoh-route-210'`;
|
|
const response = page.waitForResponse((r) => {
|
|
const url = new URL(r.url());
|
|
return (
|
|
url.pathname.endsWith('/api/encounters') &&
|
|
url.searchParams.get('location') === String(locationId)
|
|
);
|
|
});
|
|
await page.getByLabel('Encounter location', { exact: true }).selectOption('sinnoh-route-210');
|
|
const rows = await (await response).json();
|
|
const special = page.getByRole('checkbox', { name: /Include special conditions/ });
|
|
if (await special.count()) await special.check();
|
|
const expected = new Set(rows.map((r: { number: number }) => r.number)).size;
|
|
const overview = page.getByRole('group', { name: 'All Pokémon in this area' });
|
|
await expect(overview.getByRole('button')).toHaveCount(expected);
|
|
expect(expected).toBeGreaterThan(20);
|
|
const bounds = await overview.evaluate((el) => {
|
|
const parent = el.getBoundingClientRect();
|
|
return {
|
|
height: el.clientHeight,
|
|
scroll: el.scrollHeight,
|
|
fits: [...el.querySelectorAll('button')].every((b) => {
|
|
const r = b.getBoundingClientRect();
|
|
return (
|
|
r.bottom <= parent.bottom + 1 &&
|
|
r.right <= parent.right + 1 &&
|
|
r.width >= 20 &&
|
|
r.height >= 20
|
|
);
|
|
})
|
|
};
|
|
});
|
|
expect(bounds.scroll).toBeLessThanOrEqual(bounds.height + 1);
|
|
expect(bounds.fits).toBe(true);
|
|
// The overview itself always fits without a scrolling list. A large cover library
|
|
// can move the workspace below the fold, so bring that panel into view first.
|
|
await overview.scrollIntoViewIfNeeded();
|
|
await expect
|
|
.poll(() => overview.evaluate((el) => el.getBoundingClientRect().bottom <= innerHeight))
|
|
.toBe(true);
|
|
await page.screenshot({ path: '/tmp/tracker-compact-pokemon.png' });
|
|
await page.getByRole('button', { name: 'Expand encounters' }).click();
|
|
const expanded = page.getByRole('dialog');
|
|
await expect(expanded.getByRole('group').getByRole('button')).toHaveCount(expected);
|
|
await expanded.getByRole('group').getByRole('button').first().click();
|
|
const details = page.getByRole('dialog').last();
|
|
await expect(details).toContainText('Lv.');
|
|
await details.getByRole('button', { name: 'Mark as seen', exact: true }).click();
|
|
await expect(details.getByRole('button', { name: '✓ Seen · undo', exact: true })).toBeEnabled();
|
|
await details.getByRole('button', { name: 'Register a catch', exact: true }).click();
|
|
const editor = page.getByRole('dialog', { name: /^Register / });
|
|
await expect(editor).toContainText('Add to my collection');
|
|
await expect(page.locator('[role="dialog"][data-state="closed"]')).toHaveCount(0);
|
|
await editor.getByLabel('Nickname', { exact: true }).fill('Route companion');
|
|
await editor.getByRole('button', { name: 'Add to my collection', exact: true }).click();
|
|
await expect(page.getByRole('dialog')).toHaveCount(0);
|
|
await expect(overview.getByRole('button').first()).toHaveAttribute('aria-label', /caught/);
|
|
for (const [width, height] of [
|
|
[1280, 720],
|
|
[768, 1024]
|
|
]) {
|
|
await page.setViewportSize({ width, height });
|
|
await expect(overview.getByRole('button')).toHaveCount(expected);
|
|
await overview.scrollIntoViewIfNeeded();
|
|
await expect
|
|
.poll(() => overview.evaluate((el) => el.getBoundingClientRect().bottom <= innerHeight))
|
|
.toBe(true);
|
|
}
|
|
await page.setViewportSize({ width: 390, height: 844 });
|
|
await expect(page.getByRole('tab', { name: 'Area Pokémon', exact: true })).toBeVisible();
|
|
await expect(overview.getByRole('button')).toHaveCount(expected);
|
|
await overview.scrollIntoViewIfNeeded();
|
|
await expect
|
|
.poll(() => overview.evaluate((el) => el.getBoundingClientRect().bottom <= innerHeight))
|
|
.toBe(true);
|
|
await page.screenshot({ path: '/tmp/tracker-crowded-mobile.png' });
|
|
await expect(
|
|
page.getByRole('img', { name: 'Sinnoh region map from the Pokémon games' })
|
|
).not.toBeVisible();
|
|
await page.getByRole('tab', { name: 'Region map', exact: true }).click();
|
|
await page.getByRole('button', { name: 'Explore Route 201', exact: true }).click();
|
|
await expect(page.getByRole('button', { name: /Turtwig Grass/ })).toBeVisible();
|
|
await page.screenshot({ path: '/tmp/tracker-live-mobile.png' });
|
|
});
|
|
|
|
test('visible progress reacts to sightings, catches and completed stories without double-counting species', async ({
|
|
page,
|
|
playwright
|
|
}) => {
|
|
const userId = await register(page.request);
|
|
const games = await sql<
|
|
{ id: number; slug: string }[]
|
|
>`select id,slug from games where slug in ('platinum','diamond')`;
|
|
const platinum = games.find((g) => g.slug === 'platinum')!;
|
|
const diamond = games.find((g) => g.slug === 'diamond')!;
|
|
const [species] = await sql`select id from pokemon where number=387`;
|
|
await action(page.request, '/projects/game-tracker/pokemon?/setGames', {
|
|
gameIds: games.map((g) => String(g.id))
|
|
});
|
|
await page.setViewportSize({ width: 1440, height: 900 });
|
|
await page.goto('/projects/game-tracker/pokemon?game=platinum');
|
|
const collection = page.getByRole('progressbar', { name: 'National collection', exact: true });
|
|
const sightings = page.getByRole('progressbar', {
|
|
name: 'Species seen at least once',
|
|
exact: true
|
|
});
|
|
const completed = page.getByRole('progressbar', { name: 'Games completed', exact: true });
|
|
const gameProgress = page.getByRole('progressbar', { name: 'Platinum completion', exact: true });
|
|
await expect(collection).toHaveAttribute('aria-valuenow', '0');
|
|
await page.getByRole('tab', { name: 'Pokédex', exact: true }).click();
|
|
await page.getByRole('checkbox', { name: 'Seen Turtwig in Platinum', exact: true }).check();
|
|
await expect
|
|
.poll(async () => Number(await sightings.getAttribute('aria-valuenow')))
|
|
.toBeGreaterThan(0);
|
|
await expect(collection).toHaveAttribute('aria-valuenow', '0');
|
|
await page.reload();
|
|
await expect
|
|
.poll(async () => Number(await sightings.getAttribute('aria-valuenow')))
|
|
.toBeGreaterThan(0);
|
|
await page.getByRole('tab', { name: 'Pokédex', exact: true }).click();
|
|
await page.getByRole('checkbox', { name: 'Caught Turtwig in Platinum', exact: true }).check();
|
|
await expect
|
|
.poll(async () => Number(await collection.getAttribute('aria-valuenow')))
|
|
.toBeGreaterThan(0);
|
|
const first = await collection.getAttribute('aria-valuenow');
|
|
const [entry] =
|
|
await sql`select id from pokemon_game_entries where game_id=${diamond.id} and pokemon_id=${species.id}`;
|
|
await action(page.request, '/projects/game-tracker/pokemon?/togglePokemonCaught', {
|
|
entryId: String(entry.id),
|
|
caught: 'on'
|
|
});
|
|
await page.reload();
|
|
await expect(collection).toHaveAttribute('aria-valuenow', first!);
|
|
const before = Number(await gameProgress.getAttribute('aria-valuenow'));
|
|
await page.getByRole('button', { name: 'Mark story complete', exact: true }).click();
|
|
await expect(completed).toHaveAttribute('aria-valuenow', '50');
|
|
await expect
|
|
.poll(async () => Number(await gameProgress.getAttribute('aria-valuenow')))
|
|
.toBeGreaterThan(before);
|
|
await page.getByRole('tab', { name: 'Badges & milestones', exact: true }).click();
|
|
await page.getByRole('button', { name: 'Complete Coal · Roark', exact: true }).click();
|
|
await expect(
|
|
page.getByRole('progressbar', { name: 'Badges & trials', exact: true })
|
|
).toHaveAttribute('aria-valuenow', '12.5');
|
|
await page.getByRole('button', { name: 'Full breakdown', exact: true }).click();
|
|
await expect(
|
|
page.getByRole('dialog', { name: 'Your Pokémon progress' }).getByRole('progressbar')
|
|
).toHaveCount(16);
|
|
await page.screenshot({ path: '/tmp/tracker-progress-pokemon.png' });
|
|
await page.keyboard.press('Escape');
|
|
await action(page.request, '/projects/game-tracker/pokemon?/saveJourney', {
|
|
gameId: String(platinum.id),
|
|
stage: 'postgame',
|
|
location: 'sinnoh-route-201'
|
|
});
|
|
expect(
|
|
(
|
|
await sql`select completed from pokemon_journeys where user_id=${userId} and game_id=${platinum.id}`
|
|
)[0].completed
|
|
).toBe(true);
|
|
const unseen = await action(page.request, '/projects/game-tracker/pokemon?/setSeen', {
|
|
gameId: String(platinum.id),
|
|
pokemonId: String(species.id),
|
|
seen: 'off'
|
|
});
|
|
expect(unseen.status).toBe(400);
|
|
const anonymous = await playwright.request.newContext({ baseURL: origin });
|
|
expect(
|
|
(
|
|
await action(anonymous, '/projects/game-tracker/pokemon?/setSeen', {
|
|
gameId: String(platinum.id),
|
|
pokemonId: String(species.id),
|
|
seen: 'on'
|
|
})
|
|
).status
|
|
).toBe(401);
|
|
await anonymous.dispose();
|
|
await page.goto('/projects/game-tracker');
|
|
await expect(
|
|
page.getByRole('progressbar', { name: 'National collection', exact: true })
|
|
).toHaveAttribute('aria-valuenow', first!);
|
|
await expect(
|
|
page.getByRole('progressbar', { name: 'Pokémon games completed', exact: true })
|
|
).toHaveAttribute('aria-valuenow', '50');
|
|
await page.screenshot({ path: '/tmp/tracker-progress-index.png', fullPage: true });
|
|
});
|
|
|
|
test('partial COD challenges and osu goals visibly advance completion meters', async ({ page }) => {
|
|
await register(page.request);
|
|
const cod = '/projects/game-tracker/call-of-duty/black-ops-3';
|
|
const [challenge] =
|
|
await sql`select c.* from tracker_challenges c join tracker_games g on g.id=c.game_id where g.slug='black-ops-3' and c.target>=10 order by c.id limit 1`;
|
|
await page.goto(cod);
|
|
const overall = page.getByRole('progressbar', { name: 'Game completion', exact: true });
|
|
await expect(overall).toHaveAttribute('aria-valuenow', '0');
|
|
await page.getByRole('tab', { name: 'Challenges', exact: true }).click();
|
|
await page.getByLabel('Search challenges').fill(challenge.name);
|
|
const card = page
|
|
.getByRole('article')
|
|
.filter({ has: page.getByRole('heading', { name: challenge.name, exact: true }) });
|
|
await card.getByRole('spinbutton').fill(String(Math.floor(challenge.target / 2)));
|
|
await card.getByRole('button', { name: 'Save', exact: true }).click();
|
|
await expect
|
|
.poll(async () => Number(await overall.getAttribute('aria-valuenow')))
|
|
.toBeGreaterThan(0);
|
|
const partial = Number(await overall.getAttribute('aria-valuenow'));
|
|
await card.getByRole('checkbox', { name: 'Unlocked' }).check();
|
|
await card.getByRole('button', { name: 'Save', exact: true }).click();
|
|
await expect
|
|
.poll(async () => Number(await overall.getAttribute('aria-valuenow')))
|
|
.toBeGreaterThan(partial);
|
|
await page.getByRole('tab', { name: 'Weapons', exact: true }).click();
|
|
await page.screenshot({ path: '/tmp/tracker-progress-cod.png' });
|
|
await action(page.request, '/projects/game-tracker/osu?/save', {
|
|
kind: 'goal',
|
|
title: 'Progress test FC',
|
|
mode: 'osu',
|
|
goal: 'fc',
|
|
skill: 'stamina',
|
|
playedOn: '2026-09-25',
|
|
rate: '1.00',
|
|
mods: 'NM'
|
|
});
|
|
await page.goto('/projects/game-tracker/osu');
|
|
await expect(
|
|
page.getByRole('progressbar', { name: 'All map goals', exact: true })
|
|
).toHaveAttribute('aria-valuenow', '0');
|
|
await page.getByRole('button', { name: 'Achieved!', exact: true }).click();
|
|
await expect(
|
|
page.getByRole('progressbar', { name: 'All map goals', exact: true })
|
|
).toHaveAttribute('aria-valuenow', '100');
|
|
await expect(
|
|
page.getByRole('progressbar', { name: 'Full combo goals', exact: true })
|
|
).toHaveAttribute('aria-valuenow', '100');
|
|
});
|
|
|
|
test('BO2 armoury fits every gun, enforces caps and advances all rank progress with each level', async ({
|
|
page
|
|
}) => {
|
|
const userId = await register(page.request);
|
|
const cod = '/projects/game-tracker/call-of-duty/black-ops-2';
|
|
const [m27] =
|
|
await sql`select i.id from tracker_items i join tracker_games g on g.id=i.game_id where g.slug='black-ops-2' and i.name='M27'`;
|
|
await page.setViewportSize({ width: 1280, height: 720 });
|
|
await page.goto(cod);
|
|
await expect(page.locator('.weapon-card')).toHaveCount(40);
|
|
for (const [width, height] of [
|
|
[1280, 720],
|
|
[1920, 1080]
|
|
]) {
|
|
await page.setViewportSize({ width, height });
|
|
const bounds = await page.locator('.weapon-card').evaluateAll((cards) =>
|
|
cards.map((c) => {
|
|
const b = c.getBoundingClientRect();
|
|
const section = c.closest('section')!.getBoundingClientRect();
|
|
return {
|
|
name: c.getAttribute('aria-label'),
|
|
fits:
|
|
b.bottom <= section.bottom + 1 &&
|
|
b.bottom <= innerHeight &&
|
|
b.right <= innerWidth &&
|
|
b.height >= 29
|
|
};
|
|
})
|
|
);
|
|
expect(bounds.filter((b) => !b.fits)).toEqual([]);
|
|
expect(await page.evaluate(() => document.documentElement.scrollHeight)).toBeLessThanOrEqual(
|
|
height
|
|
);
|
|
await expect
|
|
.poll(() =>
|
|
page
|
|
.locator('.weapon-card img')
|
|
.evaluateAll((images) =>
|
|
images.every(
|
|
(i) => (i as HTMLImageElement).complete && (i as HTMLImageElement).naturalWidth > 0
|
|
)
|
|
)
|
|
)
|
|
.toBe(true);
|
|
}
|
|
for (const name of [
|
|
'M27',
|
|
'AN-94',
|
|
'DSR 50',
|
|
'Peacekeeper',
|
|
'MP7',
|
|
'Vector K10',
|
|
'Skorpion EVO'
|
|
]) {
|
|
await expect(
|
|
page
|
|
.getByRole('article', { name, exact: true })
|
|
.getByRole('button', { name: `View ${name} artwork` })
|
|
).toBeVisible();
|
|
}
|
|
const card = page.getByRole('article', { name: 'M27', exact: true });
|
|
const levels = page.getByRole('progressbar', { name: 'Weapon levels', exact: true });
|
|
const overall = page.getByRole('progressbar', { name: 'Game completion', exact: true });
|
|
await expect(levels).toHaveAttribute('aria-valuenow', '0');
|
|
await card.getByRole('button', { name: 'Level up M27', exact: true }).click();
|
|
await expect(card.getByRole('button', { name: 'Edit M27 level and prestige' })).toHaveText(
|
|
'LEVEL2/17'
|
|
);
|
|
const first = Number(await levels.getAttribute('aria-valuenow'));
|
|
expect(first).toBeGreaterThan(0);
|
|
const before = Number(await overall.getAttribute('aria-valuenow'));
|
|
await card.getByRole('button', { name: 'Level up M27', exact: true }).click();
|
|
await expect
|
|
.poll(async () => Number(await levels.getAttribute('aria-valuenow')))
|
|
.toBeGreaterThan(first);
|
|
await expect
|
|
.poll(async () => Number(await overall.getAttribute('aria-valuenow')))
|
|
.toBeGreaterThan(before);
|
|
expect(
|
|
(
|
|
await action(page.request, cod + '?/saveRank', {
|
|
itemId: String(m27.id),
|
|
level: '18',
|
|
prestige: '2'
|
|
})
|
|
).status
|
|
).toBe(400);
|
|
expect(
|
|
(
|
|
await action(page.request, cod + '?/saveRank', {
|
|
itemId: String(m27.id),
|
|
level: '17',
|
|
prestige: '3'
|
|
})
|
|
).status
|
|
).toBe(400);
|
|
expect(
|
|
(
|
|
await action(page.request, cod + '?/adjustWeaponRank', {
|
|
itemId: String(m27.id),
|
|
field: 'prestige',
|
|
delta: '1'
|
|
})
|
|
).status
|
|
).toBe(400);
|
|
await action(page.request, cod + '?/saveRank', {
|
|
itemId: String(m27.id),
|
|
level: '17',
|
|
prestige: '0'
|
|
});
|
|
const simultaneous = await Promise.all(
|
|
[1, 2].map(() =>
|
|
action(page.request, cod + '?/adjustWeaponRank', {
|
|
itemId: String(m27.id),
|
|
field: 'prestige',
|
|
delta: '1'
|
|
})
|
|
)
|
|
);
|
|
expect(simultaneous.filter((r) => r.status === 200)).toHaveLength(1);
|
|
expect(
|
|
(
|
|
await sql`select level,prestige from tracker_weapon_ranks where user_id=${userId} and item_id=${m27.id}`
|
|
)[0]
|
|
).toMatchObject({ level: 1, prestige: 1 });
|
|
await page.reload();
|
|
await expect(card).toContainText('18/51');
|
|
await action(page.request, cod + '?/saveRank', {
|
|
itemId: String(m27.id),
|
|
level: '17',
|
|
prestige: '2'
|
|
});
|
|
await action(page.request, cod + '?/saveRank', { level: '42', prestige: '3' });
|
|
await page.reload();
|
|
await expect(card).toContainText('51/51');
|
|
await expect(card.getByRole('button', { name: 'Level up M27' })).toBeDisabled();
|
|
await expect(page.getByRole('img', { name: 'Prestige 3 emblem' })).toBeVisible();
|
|
const progress = await overall.getAttribute('aria-valuenow');
|
|
await page.goto('/projects/game-tracker');
|
|
await expect(
|
|
page.getByRole('progressbar', { name: 'Black Ops II progress', exact: true })
|
|
).toHaveAttribute('aria-valuenow', progress!);
|
|
await page.goto(cod);
|
|
await page.setViewportSize({ width: 1280, height: 720 });
|
|
await page.screenshot({ path: '/tmp/bo2-verified-desktop.png' });
|
|
await page.setViewportSize({ width: 390, height: 844 });
|
|
await page.screenshot({ path: '/tmp/bo2-verified-mobile.png', fullPage: true });
|
|
await expect
|
|
.poll(() => page.evaluate(() => document.documentElement.scrollWidth))
|
|
.toBeLessThanOrEqual(390);
|
|
});
|