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
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,7 @@ logs
.env
.env.*
!.env.example

# Playwright
test-results/
playwright-report/
2 changes: 2 additions & 0 deletions i18n.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ export default defineI18nConfig(() => ({
PAUSE: "PAUSE",
Move: "Move",
Continue: "Continue",
"Back to menu": "Back to menu",
"Puzzle Complete!": "Puzzle Complete!",
},
},
}));
2 changes: 1 addition & 1 deletion modules/game/components/FinishModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ const submitHandler = () => {
>
<template #content>
<div class="h-full flex flex-col items-center justify-center">
<div class="text-7xl mb-6">Pause</div>
<div class="text-7xl mb-6">{{ $t("Puzzle Complete!") }}</div>
<div class="mt-4 mb-6">
<div class="flex justify-between w-[200px]">
<h2 class="font-semibold">Time</h2>
Expand Down
1 change: 0 additions & 1 deletion modules/game/composables/useEventGame.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import type { TODO } from "~core/types";
export interface Options {
openModal: () => void;
}
console.log('ss')

export async function useEventGame(
props: GameProps,
Expand Down
3 changes: 1 addition & 2 deletions nuxt.config.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({
compatibilityDate: "2024-11-10",
compatibilityDate: "2025-06-07",
app: {
head: {
title: "Perplex Image",
Expand Down Expand Up @@ -42,5 +42,4 @@ export default defineNuxtConfig({
// options here
},

compatibilityDate: "2025-06-07"
});
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@
"preview": "nuxt preview",
"start": "nuxt start",
"test": "vitest",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"test:e2e:debug": "playwright test --debug",
"lint-staged": "lint-staged",
"prepare": "husky"
},
Expand All @@ -23,6 +26,7 @@
"@nuxt/eslint": "^0.6.1",
"@nuxtjs/eslint-config-typescript": "^12.0.0",
"@nuxtjs/tailwindcss": "^6.2.0",
"@playwright/test": "^1.58.2",
"@typescript-eslint/eslint-plugin": "^8.12.2",
"@typescript-eslint/parser": "^5.62.0",
"@vitejs/plugin-vue": "^4.0.0",
Expand Down
27 changes: 27 additions & 0 deletions playwright.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { defineConfig, devices } from "@playwright/test";

export default defineConfig({
testDir: "./tests/e2e",
fullyParallel: false,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: 1,
reporter: "list",
use: {
baseURL: "http://localhost:3000",
trace: "on-first-retry",
actionTimeout: 10_000,
},
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
],
webServer: {
command: "pnpm dev",
url: "http://localhost:3000",
reuseExistingServer: !process.env.CI,
timeout: 120_000,
},
});
38 changes: 38 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

92 changes: 92 additions & 0 deletions tests/e2e/advanced.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { test, expect, mockPhoto, mockApiRoutes } from "./fixtures";

test.describe("Game page – layout", () => {
test("displays puzzle pieces", async ({ gamePage }) => {
const pieces = gamePage.locator("[draggable='true']");
await expect(pieces.first()).toBeVisible();
});

test("displays the correct number of pieces for 9x13 level", async ({
gamePage,
}) => {
// mockPhoto: height=800, width=1200 → cols=9, rows=Math.round(800/1200*9)=6 → 54 pieces
const pieces = gamePage.locator("[draggable='true']");
await expect(pieces).toHaveCount(54);
});

test("displays the image preview thumbnail", async ({ gamePage }) => {
const preview = gamePage.locator("img[alt='']").first();
await expect(preview).toBeVisible();
});

test("displays move counter starting at 0", async ({ gamePage }) => {
await expect(gamePage.getByText(/0 Move/)).toBeVisible();
});

test("displays stopwatch starting at 00:00:00", async ({ gamePage }) => {
await expect(gamePage.getByText("00:00:00")).toBeVisible();
});

test("displays RESTART button", async ({ gamePage }) => {
await expect(
gamePage.getByRole("button", { name: /RESTART/i })
).toBeVisible();
});

test("displays PAUSE button", async ({ gamePage }) => {
await expect(
gamePage.getByRole("button", { name: /PAUSE/i })
).toBeVisible();
});
});

test.describe("Game page – pause flow", () => {
test("opens pause modal when PAUSE is clicked", async ({ gamePage }) => {
await gamePage.getByRole("button", { name: /PAUSE/i }).click();
await expect(gamePage.locator("div.fixed")).toBeVisible();
await expect(gamePage.locator("div.text-7xl")).toContainText("Pause");
});

test("pause modal shows Continue button", async ({ gamePage }) => {
await gamePage.getByRole("button", { name: /PAUSE/i }).click();
await expect(
gamePage.getByRole("button", { name: /Continue/i })
).toBeVisible();
});

test("continue button closes pause modal", async ({ gamePage }) => {
await gamePage.getByRole("button", { name: /PAUSE/i }).click();
await gamePage.getByRole("button", { name: /Continue/i }).click();
await expect(gamePage.locator("div.fixed")).not.toBeVisible();
});
});

test.describe("Game page – restart", () => {
test("RESTART resets move counter to 0", async ({ gamePage }) => {
await gamePage.getByRole("button", { name: /RESTART/i }).click();
await expect(gamePage.getByText(/0 Move/)).toBeVisible();
});

test("RESTART resets stopwatch to 00:00:00", async ({ gamePage }) => {
await gamePage.getByRole("button", { name: /RESTART/i }).click();
await expect(gamePage.getByText("00:00:00")).toBeVisible();
});
});

test.describe("Game page – levels", () => {
test("15x23 level renders correct piece count", async ({ page }) => {
await mockApiRoutes(page);
await page.goto(`/game/${mockPhoto.id}?level=15x23`);
await page.waitForSelector("[draggable='true']", { timeout: 15_000 });
// mockPhoto: height=800, width=1200 → cols=15, rows=Math.round(800/1200*15)=10 → 150 pieces
await expect(page.locator("[draggable='true']")).toHaveCount(150);
});

test("18x26 level renders correct piece count", async ({ page }) => {
await mockApiRoutes(page);
await page.goto(`/game/${mockPhoto.id}?level=18x26`);
await page.waitForSelector("[draggable='true']", { timeout: 15_000 });
// mockPhoto: height=800, width=1200 → cols=18, rows=Math.round(800/1200*18)=12 → 216 pieces
await expect(page.locator("[draggable='true']")).toHaveCount(216);
});
});
92 changes: 92 additions & 0 deletions tests/e2e/fixtures.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { test as base, type Page } from "@playwright/test";

/** A minimal PexelPhoto fixture served via the local dev-server image. */
export const mockPhoto = {
id: 1,
alt: "Test photo",
avg_color: "#808080",
height: 800,
width: 1200,
liked: false,
photographer: "Test Photographer",
photographer_id: 1,
photographer_url: "https://www.pexels.com/@test",
src: {
landscape: "http://localhost:3000/img/img.jpeg",
large: "http://localhost:3000/img/img.jpeg",
large2x: "http://localhost:3000/img/img.jpeg",
medium: "http://localhost:3000/img/img.jpeg",
original: "http://localhost:3000/img/img.jpeg",
portrait: "http://localhost:3000/img/img.jpeg",
small: "http://localhost:3000/img/img.jpeg",
tiny: "http://localhost:3000/img/img.jpeg",
url: "http://localhost:3000/img/img.jpeg",
},
url: "https://www.pexels.com/photo/1",
};

export const mockPhotosResponse = {
id: "dyck2i1",
media: [mockPhoto],
page: 1,
per_page: 1,
total_results: 1,
};

/**
* Intercept the Pexels API proxy routes so tests never call the real API.
*
* IMPORTANT: register the more-specific `get-image` route FIRST (lower priority)
* and `get-images` LAST (higher priority), because Playwright's last-registered
* route wins when multiple patterns match the same URL.
* `**\/api\/get-image**` would otherwise also match `get-images` requests.
*/
export async function mockApiRoutes(page: Page) {
// Lower priority – matches /api/get-image?id=... only when get-images does not match
await page.route("**/api/get-image**", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(mockPhoto),
})
);
// Higher priority – must be registered LAST so it takes precedence over get-image
await page.route("**/api/get-images**", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(mockPhotosResponse),
})
);
}

type Fixtures = {
homePage: Page;
gamePage: Page;
};

export const test = base.extend<Fixtures>({
homePage: async ({ page }, use) => {
await mockApiRoutes(page);
// Register the response waiter BEFORE navigating to catch the onMounted fetch
const getImagesResponse = page.waitForResponse("**/api/get-images**", {
timeout: 30_000,
});
await page.goto("/");
// Wait for the getImages() API call to be fulfilled by the mock
await getImagesResponse;
// Wait for loading to complete: the shimmer disappears and the image appears
await page.waitForSelector("img.w-full", { timeout: 15_000 });
await use(page);
},

gamePage: async ({ page }, use) => {
await mockApiRoutes(page);
await page.goto(`/game/${mockPhoto.id}?level=9x13`);
// Wait for at least one puzzle piece to be rendered
await page.waitForSelector("[draggable='true']", { timeout: 15_000 });
await use(page);
},
});

export { expect } from "@playwright/test";
Loading
Loading