Skip to content

Commit b4bdfda

Browse files
Add Playwright E2E test suite and fix application bugs (#24)
* Initial plan * Add Playwright E2E tests and fix code issues Co-authored-by: makuchpatryk <19875907+makuchpatryk@users.noreply.github.com> * Refactor level tests to reuse mockApiRoutes and fix JSDoc comment Co-authored-by: makuchpatryk <19875907+makuchpatryk@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: makuchpatryk <19875907+makuchpatryk@users.noreply.github.com>
1 parent d0c551f commit b4bdfda

11 files changed

Lines changed: 352 additions & 4 deletions

File tree

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,3 +25,7 @@ logs
2525
.env
2626
.env.*
2727
!.env.example
28+
29+
# Playwright
30+
test-results/
31+
playwright-report/

i18n.config.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ export default defineI18nConfig(() => ({
1313
PAUSE: "PAUSE",
1414
Move: "Move",
1515
Continue: "Continue",
16+
"Back to menu": "Back to menu",
17+
"Puzzle Complete!": "Puzzle Complete!",
1618
},
1719
},
1820
}));

modules/game/components/FinishModal.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ const submitHandler = () => {
3232
>
3333
<template #content>
3434
<div class="h-full flex flex-col items-center justify-center">
35-
<div class="text-7xl mb-6">Pause</div>
35+
<div class="text-7xl mb-6">{{ $t("Puzzle Complete!") }}</div>
3636
<div class="mt-4 mb-6">
3737
<div class="flex justify-between w-[200px]">
3838
<h2 class="font-semibold">Time</h2>

modules/game/composables/useEventGame.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import type { TODO } from "~core/types";
44
export interface Options {
55
openModal: () => void;
66
}
7-
console.log('ss')
87

98
export async function useEventGame(
109
props: GameProps,

nuxt.config.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
// https://nuxt.com/docs/api/configuration/nuxt-config
22
export default defineNuxtConfig({
3-
compatibilityDate: "2024-11-10",
3+
compatibilityDate: "2025-06-07",
44
app: {
55
head: {
66
title: "Perplex Image",
@@ -42,5 +42,4 @@ export default defineNuxtConfig({
4242
// options here
4343
},
4444

45-
compatibilityDate: "2025-06-07"
4645
});

package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@
1313
"preview": "nuxt preview",
1414
"start": "nuxt start",
1515
"test": "vitest",
16+
"test:e2e": "playwright test",
17+
"test:e2e:ui": "playwright test --ui",
18+
"test:e2e:debug": "playwright test --debug",
1619
"lint-staged": "lint-staged",
1720
"prepare": "husky"
1821
},
@@ -23,6 +26,7 @@
2326
"@nuxt/eslint": "^0.6.1",
2427
"@nuxtjs/eslint-config-typescript": "^12.0.0",
2528
"@nuxtjs/tailwindcss": "^6.2.0",
29+
"@playwright/test": "^1.58.2",
2630
"@typescript-eslint/eslint-plugin": "^8.12.2",
2731
"@typescript-eslint/parser": "^5.62.0",
2832
"@vitejs/plugin-vue": "^4.0.0",

playwright.config.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { defineConfig, devices } from "@playwright/test";
2+
3+
export default defineConfig({
4+
testDir: "./tests/e2e",
5+
fullyParallel: false,
6+
forbidOnly: !!process.env.CI,
7+
retries: process.env.CI ? 2 : 0,
8+
workers: 1,
9+
reporter: "list",
10+
use: {
11+
baseURL: "http://localhost:3000",
12+
trace: "on-first-retry",
13+
actionTimeout: 10_000,
14+
},
15+
projects: [
16+
{
17+
name: "chromium",
18+
use: { ...devices["Desktop Chrome"] },
19+
},
20+
],
21+
webServer: {
22+
command: "pnpm dev",
23+
url: "http://localhost:3000",
24+
reuseExistingServer: !process.env.CI,
25+
timeout: 120_000,
26+
},
27+
});

pnpm-lock.yaml

Lines changed: 38 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

tests/e2e/advanced.spec.ts

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { test, expect, mockPhoto, mockApiRoutes } from "./fixtures";
2+
3+
test.describe("Game page – layout", () => {
4+
test("displays puzzle pieces", async ({ gamePage }) => {
5+
const pieces = gamePage.locator("[draggable='true']");
6+
await expect(pieces.first()).toBeVisible();
7+
});
8+
9+
test("displays the correct number of pieces for 9x13 level", async ({
10+
gamePage,
11+
}) => {
12+
// mockPhoto: height=800, width=1200 → cols=9, rows=Math.round(800/1200*9)=6 → 54 pieces
13+
const pieces = gamePage.locator("[draggable='true']");
14+
await expect(pieces).toHaveCount(54);
15+
});
16+
17+
test("displays the image preview thumbnail", async ({ gamePage }) => {
18+
const preview = gamePage.locator("img[alt='']").first();
19+
await expect(preview).toBeVisible();
20+
});
21+
22+
test("displays move counter starting at 0", async ({ gamePage }) => {
23+
await expect(gamePage.getByText(/0 Move/)).toBeVisible();
24+
});
25+
26+
test("displays stopwatch starting at 00:00:00", async ({ gamePage }) => {
27+
await expect(gamePage.getByText("00:00:00")).toBeVisible();
28+
});
29+
30+
test("displays RESTART button", async ({ gamePage }) => {
31+
await expect(
32+
gamePage.getByRole("button", { name: /RESTART/i })
33+
).toBeVisible();
34+
});
35+
36+
test("displays PAUSE button", async ({ gamePage }) => {
37+
await expect(
38+
gamePage.getByRole("button", { name: /PAUSE/i })
39+
).toBeVisible();
40+
});
41+
});
42+
43+
test.describe("Game page – pause flow", () => {
44+
test("opens pause modal when PAUSE is clicked", async ({ gamePage }) => {
45+
await gamePage.getByRole("button", { name: /PAUSE/i }).click();
46+
await expect(gamePage.locator("div.fixed")).toBeVisible();
47+
await expect(gamePage.locator("div.text-7xl")).toContainText("Pause");
48+
});
49+
50+
test("pause modal shows Continue button", async ({ gamePage }) => {
51+
await gamePage.getByRole("button", { name: /PAUSE/i }).click();
52+
await expect(
53+
gamePage.getByRole("button", { name: /Continue/i })
54+
).toBeVisible();
55+
});
56+
57+
test("continue button closes pause modal", async ({ gamePage }) => {
58+
await gamePage.getByRole("button", { name: /PAUSE/i }).click();
59+
await gamePage.getByRole("button", { name: /Continue/i }).click();
60+
await expect(gamePage.locator("div.fixed")).not.toBeVisible();
61+
});
62+
});
63+
64+
test.describe("Game page – restart", () => {
65+
test("RESTART resets move counter to 0", async ({ gamePage }) => {
66+
await gamePage.getByRole("button", { name: /RESTART/i }).click();
67+
await expect(gamePage.getByText(/0 Move/)).toBeVisible();
68+
});
69+
70+
test("RESTART resets stopwatch to 00:00:00", async ({ gamePage }) => {
71+
await gamePage.getByRole("button", { name: /RESTART/i }).click();
72+
await expect(gamePage.getByText("00:00:00")).toBeVisible();
73+
});
74+
});
75+
76+
test.describe("Game page – levels", () => {
77+
test("15x23 level renders correct piece count", async ({ page }) => {
78+
await mockApiRoutes(page);
79+
await page.goto(`/game/${mockPhoto.id}?level=15x23`);
80+
await page.waitForSelector("[draggable='true']", { timeout: 15_000 });
81+
// mockPhoto: height=800, width=1200 → cols=15, rows=Math.round(800/1200*15)=10 → 150 pieces
82+
await expect(page.locator("[draggable='true']")).toHaveCount(150);
83+
});
84+
85+
test("18x26 level renders correct piece count", async ({ page }) => {
86+
await mockApiRoutes(page);
87+
await page.goto(`/game/${mockPhoto.id}?level=18x26`);
88+
await page.waitForSelector("[draggable='true']", { timeout: 15_000 });
89+
// mockPhoto: height=800, width=1200 → cols=18, rows=Math.round(800/1200*18)=12 → 216 pieces
90+
await expect(page.locator("[draggable='true']")).toHaveCount(216);
91+
});
92+
});

tests/e2e/fixtures.ts

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { test as base, type Page } from "@playwright/test";
2+
3+
/** A minimal PexelPhoto fixture served via the local dev-server image. */
4+
export const mockPhoto = {
5+
id: 1,
6+
alt: "Test photo",
7+
avg_color: "#808080",
8+
height: 800,
9+
width: 1200,
10+
liked: false,
11+
photographer: "Test Photographer",
12+
photographer_id: 1,
13+
photographer_url: "https://www.pexels.com/@test",
14+
src: {
15+
landscape: "http://localhost:3000/img/img.jpeg",
16+
large: "http://localhost:3000/img/img.jpeg",
17+
large2x: "http://localhost:3000/img/img.jpeg",
18+
medium: "http://localhost:3000/img/img.jpeg",
19+
original: "http://localhost:3000/img/img.jpeg",
20+
portrait: "http://localhost:3000/img/img.jpeg",
21+
small: "http://localhost:3000/img/img.jpeg",
22+
tiny: "http://localhost:3000/img/img.jpeg",
23+
url: "http://localhost:3000/img/img.jpeg",
24+
},
25+
url: "https://www.pexels.com/photo/1",
26+
};
27+
28+
export const mockPhotosResponse = {
29+
id: "dyck2i1",
30+
media: [mockPhoto],
31+
page: 1,
32+
per_page: 1,
33+
total_results: 1,
34+
};
35+
36+
/**
37+
* Intercept the Pexels API proxy routes so tests never call the real API.
38+
*
39+
* IMPORTANT: register the more-specific `get-image` route FIRST (lower priority)
40+
* and `get-images` LAST (higher priority), because Playwright's last-registered
41+
* route wins when multiple patterns match the same URL.
42+
* `**\/api\/get-image**` would otherwise also match `get-images` requests.
43+
*/
44+
export async function mockApiRoutes(page: Page) {
45+
// Lower priority – matches /api/get-image?id=... only when get-images does not match
46+
await page.route("**/api/get-image**", (route) =>
47+
route.fulfill({
48+
status: 200,
49+
contentType: "application/json",
50+
body: JSON.stringify(mockPhoto),
51+
})
52+
);
53+
// Higher priority – must be registered LAST so it takes precedence over get-image
54+
await page.route("**/api/get-images**", (route) =>
55+
route.fulfill({
56+
status: 200,
57+
contentType: "application/json",
58+
body: JSON.stringify(mockPhotosResponse),
59+
})
60+
);
61+
}
62+
63+
type Fixtures = {
64+
homePage: Page;
65+
gamePage: Page;
66+
};
67+
68+
export const test = base.extend<Fixtures>({
69+
homePage: async ({ page }, use) => {
70+
await mockApiRoutes(page);
71+
// Register the response waiter BEFORE navigating to catch the onMounted fetch
72+
const getImagesResponse = page.waitForResponse("**/api/get-images**", {
73+
timeout: 30_000,
74+
});
75+
await page.goto("/");
76+
// Wait for the getImages() API call to be fulfilled by the mock
77+
await getImagesResponse;
78+
// Wait for loading to complete: the shimmer disappears and the image appears
79+
await page.waitForSelector("img.w-full", { timeout: 15_000 });
80+
await use(page);
81+
},
82+
83+
gamePage: async ({ page }, use) => {
84+
await mockApiRoutes(page);
85+
await page.goto(`/game/${mockPhoto.id}?level=9x13`);
86+
// Wait for at least one puzzle piece to be rendered
87+
await page.waitForSelector("[draggable='true']", { timeout: 15_000 });
88+
await use(page);
89+
},
90+
});
91+
92+
export { expect } from "@playwright/test";

0 commit comments

Comments
 (0)