Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -88,3 +88,8 @@ Thumbs.db
# ===================
CLAUDE.md
.claude/

# Playwright
frontend/test-results/
frontend/playwright-report/
frontend/.playwright/
33 changes: 33 additions & 0 deletions frontend/e2e/dashboard.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { test, expect } from '@playwright/test';
import { mockApi, type ApiCall } from './mocks';

test.describe('Dashboard', () => {
let calls: ApiCall[];

test.beforeEach(async ({ page }) => {
calls = await mockApi(page);
await page.goto('/dashboard');
});

test('renders the stats from the analytics endpoint', async ({ page }) => {
await expect(page.getByText('Current Points')).toBeVisible();
await expect(page.getByText('342', { exact: true })).toBeVisible();
await expect(page.getByText('Active Giveaways')).toBeVisible();
await expect(page.getByText('57', { exact: true })).toBeVisible();
await expect(page.getByText('Win Rate (30d)')).toBeVisible();
});

test('root path redirects to the dashboard', async ({ page }) => {
await page.goto('/');
await expect(page).toHaveURL(/\/dashboard$/);
});

test('stopping the scheduler calls the stop endpoint', async ({ page }) => {
// Scheduler is mocked as running, so a stop control must be available.
await page.getByRole('button', { name: /stop/i }).first().click();

await expect
.poll(() => calls.some((c) => c.method === 'POST' && c.url === '/api/v1/scheduler/stop'))
.toBe(true);
});
});
37 changes: 37 additions & 0 deletions frontend/e2e/giveaways.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { test, expect } from '@playwright/test';
import { mockApi, type ApiCall } from './mocks';

test.describe('Giveaways', () => {
let calls: ApiCall[];

test.beforeEach(async ({ page }) => {
calls = await mockApi(page);
await page.goto('/giveaways');
});

test('lists the mocked giveaways', async ({ page }) => {
await expect(page.getByText('Portal Reloaded')).toBeVisible();
await expect(page.getByText('Half-Life 3')).toBeVisible();
await expect(page.getByText('Stardew Galaxy')).toBeVisible();
});

test('entering a giveaway posts to the enter endpoint', async ({ page }) => {
// The first non-entered card exposes an Enter button.
await page.getByRole('button', { name: 'Enter', exact: true }).first().click();

await expect
.poll(() =>
calls.some((c) => c.method === 'POST' && /\/giveaways\/Code\d+\/enter$/.test(c.url))
)
.toBe(true);
});

test('searching narrows the list', async ({ page }) => {
const search = page.getByPlaceholder(/search/i);
await search.fill('Portal');
await search.press('Enter');

await expect(page.getByText('Portal Reloaded')).toBeVisible();
await expect(page.getByText('Half-Life 3')).not.toBeVisible();
});
});
203 changes: 203 additions & 0 deletions frontend/e2e/mocks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
import type { Page, Route } from '@playwright/test';

/** Envelope every backend response uses. */
function ok(data: unknown) {
return {
status: 200,
contentType: 'application/json',
body: JSON.stringify({ success: true, data }),
};
}

const NOW = Date.now();
const inHours = (h: number) => new Date(NOW + h * 3_600_000).toISOString();

export const mockSettings = {
id: 1,
phpsessid: 'e2e-session-cookie',
user_agent: 'SteamSelfGifter/2.0',
xsrf_token: null,
dlc_enabled: false,
safety_check_enabled: true,
auto_hide_unsafe: true,
autojoin_enabled: true,
autojoin_start_at: 300,
autojoin_stop_at: 50,
autojoin_min_price: 10,
autojoin_min_score: 7,
autojoin_min_reviews: 1000,
autojoin_max_game_age: null,
scan_interval_minutes: 30,
max_entries_per_cycle: 10,
automation_enabled: true,
max_scan_pages: 3,
entry_delay_min: 5,
entry_delay_max: 15,
last_synced_at: null,
created_at: '2026-01-01T00:00:00',
updated_at: '2026-01-01T00:00:00',
};

export const mockDashboard = {
session: { configured: true, valid: true, username: 'e2e-user', error: null },
points: { current: 342 },
entries: { total: 120, today: 4, entered_30d: 60, wins_30d: 3, win_rate: 5.0 },
giveaways: { active: 57, entered: 12, wins: 9 },
safety: { checked: 40, safe: 38, unsafe: 2, unchecked: 17 },
scheduler: { running: true, paused: false, last_scan: inHours(-1), next_scan: inHours(1) },
};

export const mockSchedulerStatus = {
running: true,
paused: false,
job_count: 1,
jobs: [
{ id: 'automation_cycle', name: 'scan_giveaways', next_run: inHours(1), pending: false },
],
};

function giveaway(id: number, over: Record<string, unknown> = {}) {
return {
id,
code: `Code${id}`,
url: `https://www.steamgifts.com/giveaway/Code${id}/`,
game_name: `Test Game ${id}`,
game_id: 400 + id,
price: 25 + id,
copies: 1,
end_time: inHours(24 + id),
discovered_at: inHours(-2),
entered_at: null,
is_hidden: false,
is_entered: false,
is_wishlist: false,
is_won: false,
won_at: null,
is_safe: true,
safety_score: 95,
created_at: inHours(-2),
updated_at: inHours(-2),
game_thumbnail: null,
game_review_score: 9,
game_total_reviews: 12000,
game_review_summary: 'Very Positive',
eligibility_reason: null,
eligibility_checked_at: null,
...over,
};
}

export const mockGiveaways = [
giveaway(1, { game_name: 'Portal Reloaded' }),
giveaway(2, { game_name: 'Half-Life 3', is_wishlist: true }),
giveaway(3, { game_name: 'Stardew Galaxy', is_entered: true, entered_at: inHours(-1) }),
];

export interface ApiCall {
method: string;
url: string;
postData: string | null;
}

/**
* Install route mocks for every /api/v1 endpoint the app touches and swallow
* the WebSocket connection. Returns a log of API calls for assertions.
*/
export async function mockApi(page: Page): Promise<ApiCall[]> {
const calls: ApiCall[] = [];

// The app connects to /ws/events on load; accept and stay silent.
await page.routeWebSocket('**/ws/events', () => {
/* no server messages */
});

await page.route('**/api/v1/**', async (route: Route) => {
const req = route.request();
const url = new URL(req.url());
const path = url.pathname;
const method = req.method();
calls.push({ method, url: path + url.search, postData: req.postData() });

// --- Settings ---
if (path === '/api/v1/settings' && method === 'GET') {
return route.fulfill(ok(mockSettings));
}
if (path === '/api/v1/settings' && method === 'PUT') {
const body = JSON.parse(req.postData() ?? '{}');
return route.fulfill(
ok({ ...mockSettings, ...body, updated_at: new Date(NOW).toISOString() })
);
}
if (path === '/api/v1/settings/test-session') {
return route.fulfill(ok({ valid: true, username: 'e2e-user', points: 342, error: null }));
}

// --- Analytics / dashboard ---
if (path === '/api/v1/analytics/dashboard') {
return route.fulfill(ok(mockDashboard));
}

// --- Scheduler ---
if (path === '/api/v1/scheduler/status') {
return route.fulfill(ok(mockSchedulerStatus));
}
if (path === '/api/v1/scheduler/stop') {
return route.fulfill(ok({ ...mockSchedulerStatus, running: false, jobs: [] }));
}
if (path === '/api/v1/scheduler/start') {
return route.fulfill(ok(mockSchedulerStatus));
}

// --- Giveaways ---
if (/^\/api\/v1\/giveaways\/[^/]+\/enter$/.test(path)) {
return route.fulfill(ok({ success: true, points_spent: 26, error: null }));
}
if (path.startsWith('/api/v1/giveaways')) {
const search = url.searchParams.get('search')?.toLowerCase();
const filtered = search
? mockGiveaways.filter((g) => g.game_name.toLowerCase().includes(search))
: mockGiveaways;
return route.fulfill(ok({ giveaways: filtered, count: filtered.length }));
}

// --- Entries (History page maps {entries, count} -> {items, total}) ---
if (path.startsWith('/api/v1/entries')) {
return route.fulfill(ok({ entries: [], count: 0 }));
}

// --- Analytics summaries (Analytics page) ---
if (path === '/api/v1/analytics/entries/summary') {
return route.fulfill(
ok({
total: 120,
successful: 117,
failed: 3,
total_points_spent: 2900,
success_rate: 97.5,
by_type: { auto: 100, manual: 15, wishlist: 5 },
})
);
}
if (path === '/api/v1/analytics/giveaways/summary') {
return route.fulfill(
ok({ total: 300, active: 57, entered: 12, hidden: 4, wins: 9, win_rate: 5.0 })
);
}
if (path === '/api/v1/analytics/games/summary') {
return route.fulfill(ok({ total: 200, games: 180, dlc: 15, bundles: 5, stale_count: 3 }));
}
if (path.startsWith('/api/v1/analytics/entries/trends')) {
return route.fulfill(ok({ period: 'week', trends: [] }));
}

// --- System logs (Dashboard activity feed / Logs page) ---
if (path.startsWith('/api/v1/system/logs')) {
return route.fulfill(ok({ logs: [], count: 0 }));
}

// --- Fallback: empty success so unmodelled endpoints don't hang ---
return route.fulfill(ok({}));
});

return calls;
}
31 changes: 31 additions & 0 deletions frontend/e2e/navigation.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { test, expect } from '@playwright/test';
import { mockApi } from './mocks';

test.describe('Navigation', () => {
test.beforeEach(async ({ page }) => {
await mockApi(page);
await page.goto('/dashboard');
});

test('sidebar links reach every page', async ({ page }) => {
const pages: Array<[RegExp | string, RegExp]> = [
['Giveaways', /\/giveaways$/],
['Wins', /\/wins$/],
['History', /\/history$/],
['Analytics', /\/analytics$/],
['Logs', /\/logs$/],
['Settings', /\/settings$/],
['Dashboard', /\/dashboard$/],
];

for (const [name, url] of pages) {
await page.getByRole('link', { name }).first().click();
await expect(page).toHaveURL(url);
}
});

test('unknown routes fall back to the dashboard', async ({ page }) => {
await page.goto('/definitely-not-a-page');
await expect(page).toHaveURL(/\/dashboard$/);
});
});
37 changes: 37 additions & 0 deletions frontend/e2e/settings.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { test, expect } from '@playwright/test';
import { mockApi, type ApiCall } from './mocks';

test.describe('Settings', () => {
let calls: ApiCall[];

test.beforeEach(async ({ page }) => {
calls = await mockApi(page);
await page.goto('/settings');
});

test('loads current settings into the form', async ({ page }) => {
await expect(page.getByLabel('PHPSESSID', { exact: true })).toHaveValue('e2e-session-cookie');
// The page renders a save button in the header and one at the bottom.
await expect(page.getByRole('button', { name: 'Save Changes' }).first()).toBeDisabled();
});

test('editing PHPSESSID enables save and PUTs the new value', async ({ page }) => {
const field = page.getByLabel('PHPSESSID', { exact: true });
await field.fill('new-cookie-value');

const save = page.getByRole('button', { name: 'Save Changes' }).first();
await expect(save).toBeEnabled();
await save.click();

await expect(page.getByText('Settings saved successfully')).toBeVisible();

const put = calls.find((c) => c.method === 'PUT' && c.url === '/api/v1/settings');
expect(put).toBeTruthy();
expect(JSON.parse(put!.postData!)).toMatchObject({ phpsessid: 'new-cookie-value' });
});

test('test session reports the mocked user', async ({ page }) => {
await page.getByRole('button', { name: /test session/i }).click();
await expect(page.getByText(/session valid.*e2e-user/i)).toBeVisible();
});
});
Loading
Loading