diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 53e9e25..621417b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -74,3 +74,44 @@ jobs: name: coverage-report path: coverage/ retention-days: 7 + + e2e: + name: Playwright E2E + runs-on: ubuntu-latest + timeout-minutes: 20 + needs: [lint, test] + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + run_install: false + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Install Playwright Chromium + run: pnpm exec playwright install --with-deps chromium + + - name: Build application + run: pnpm build + + - name: Run Playwright suite + run: pnpm test:e2e + + - name: Upload Playwright artefacts + uses: actions/upload-artifact@v4 + if: failure() + with: + name: playwright-results + path: output/playwright/ + retention-days: 7 diff --git a/.gitignore b/.gitignore index 20d954c..0aa2795 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,7 @@ # testing /coverage +/output/playwright/ # next.js /.next/ @@ -59,4 +60,4 @@ terminal-commands.md .cursor skills/ .mcp.json -skills-lock.json \ No newline at end of file +skills-lock.json diff --git a/README.md b/README.md index ced34af..dcdf12e 100644 --- a/README.md +++ b/README.md @@ -302,9 +302,22 @@ To force a full reseed (truncates existing data first), call `GET /api/embedding ```bash pnpm test # run the Jest suite +pnpm test:integration # run the colocated integration tests only pnpm test:coverage # coverage report -- 95% threshold enforced +pnpm test:e2e # run the Playwright browser suite ``` +The first-pass Playwright coverage is deterministic by design: it stubs `/api/recommendations` and TMDb responses in the browser, so the suite does not depend on live AI, worker, or poster services. + +To run Playwright locally, make sure the browser binary is installed once: + +```bash +pnpm exec playwright install chromium +pnpm test:e2e +``` + +`pnpm test:e2e` reuses an existing local server when one is already running on `http://127.0.0.1:3000`; otherwise it builds the app and starts a local production server automatically. + ### Deployment Deploy the Next.js app to Vercel: @@ -337,11 +350,11 @@ This project uses GitHub Actions for continuous integration and automated Cloudf ### Workflows -| Workflow | Trigger | What it does | -| ------------------------ | --------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | -| `ci.yml` | Push / PR → `main` | ESLint, TypeScript type-check, Jest (95% coverage threshold). Uploads a coverage report artifact and auto-commits a coverage badge to `main`. | -| `deploy.yml` | Push → `main` (worker files only) | Deploys the Supabase Cloudflare Worker via Wrangler. | -| `supabase-keepalive.yml` | Daily schedule | Runs a keepalive query against Supabase so the free-tier project stays active. | +| Workflow | Trigger | What it does | +| ------------------------ | --------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | +| `ci.yml` | Push / PR → `main` | ESLint, TypeScript type-check, Jest coverage, and a deterministic Playwright Chromium suite. Uploads coverage and Playwright artefacts when needed. | +| `deploy.yml` | Push → `main` (worker files only) | Deploys the Supabase Cloudflare Worker via Wrangler. | +| `supabase-keepalive.yml` | Daily schedule | Runs a keepalive query against Supabase so the free-tier project stays active. | ### Required GitHub secrets @@ -391,8 +404,12 @@ plotline-ai/ │ └── supabase-worker.ts # Cloudflare Worker for Supabase operations ├── public/ │ └── constants/movies.txt # Movie corpus for embedding seeding +├── tests/ +│ ├── e2e/ # Playwright browser specs + route stubs +│ └── support/ # Shared deterministic test fixtures ├── wrangler.supabase.toml ├── jest.config.js +├── playwright.config.ts ├── tailwind.config.ts └── package.json ``` diff --git a/app/(routes)/movieForm/MovieFormClient.integration.test.tsx b/app/(routes)/movieForm/MovieFormClient.integration.test.tsx new file mode 100644 index 0000000..53de9a7 --- /dev/null +++ b/app/(routes)/movieForm/MovieFormClient.integration.test.tsx @@ -0,0 +1,121 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { useRouter } from "next/navigation"; +import { useEffect, useState } from "react"; +import MovieFormClient from "./MovieFormClient"; +import { MovieProvider, useMovieContext } from "@/contexts/MovieContext"; + +jest.mock("next/navigation", () => ({ + useRouter: jest.fn(), +})); + +const mockPush = jest.fn(); + +function MovieFormSessionInitialiser({ + children, + totalParticipants, +}: { + children: React.ReactNode; + totalParticipants: number; +}) { + const { setParticipantsData, setTotalParticipants } = useMovieContext(); + const [isReady, setIsReady] = useState(false); + + useEffect(() => { + setParticipantsData([]); + setTotalParticipants(totalParticipants); + setIsReady(true); + }, [setParticipantsData, setTotalParticipants, totalParticipants]); + + return isReady ? <>{children} : null; +} + +function MovieContextSnapshot() { + const { participantsData, totalParticipants } = useMovieContext(); + + return ( + <> + {participantsData.length} + {JSON.stringify(participantsData)} + {totalParticipants} + + ); +} + +function renderMovieForm(totalParticipants = 2) { + return render( + + + + + + + ); +} + +describe("MovieFormClient integration", () => { + beforeEach(() => { + jest.clearAllMocks(); + (useRouter as jest.Mock).mockReturnValue({ push: mockPush }); + }); + + it("accumulates participant data across steps, resets the form, and only navigates after the last participant", async () => { + renderMovieForm(2); + + fireEvent.change(screen.getByLabelText(/what's your favourite movie and why/i), { + target: { value: "I love practical action movies with huge set pieces." }, + }); + fireEvent.change( + screen.getByLabelText( + /which famous film person would you love to be stranded on an island with/i + ), + { + target: { value: "George Miller" }, + } + ); + + fireEvent.click(screen.getByRole("button", { name: "Next" })); + + await waitFor(() => { + expect(screen.getByRole("heading", { name: "Person #2" })).toBeInTheDocument(); + }); + + expect(mockPush).not.toHaveBeenCalled(); + expect(screen.getByTestId("participants-count")).toHaveTextContent("1"); + expect(screen.getByTestId("participants-json")).toHaveTextContent( + "I love practical action movies with huge set pieces." + ); + expect(screen.getByTestId("total-participants")).toHaveTextContent("2"); + expect(screen.getByLabelText(/what's your favourite movie and why/i)).toHaveValue(""); + expect( + screen.getByLabelText( + /which famous film person would you love to be stranded on an island with/i + ) + ).toHaveValue(""); + + fireEvent.change(screen.getByLabelText(/what's your favourite movie and why/i), { + target: { value: "I want something cosy and funny for the second pick." }, + }); + fireEvent.change( + screen.getByLabelText( + /which famous film person would you love to be stranded on an island with/i + ), + { + target: { value: "Olivia Colman" }, + } + ); + + fireEvent.click(screen.getByRole("button", { name: "Get Movie" })); + + await waitFor(() => { + expect(mockPush).toHaveBeenCalledWith("/recommendations"); + }); + + expect(screen.getByTestId("participants-count")).toHaveTextContent("2"); + expect(screen.getByTestId("participants-json")).toHaveTextContent( + "I love practical action movies with huge set pieces." + ); + expect(screen.getByTestId("participants-json")).toHaveTextContent( + "I want something cosy and funny for the second pick." + ); + }); +}); diff --git a/app/(routes)/recommendations/RecommendationsClient.integration.test.tsx b/app/(routes)/recommendations/RecommendationsClient.integration.test.tsx new file mode 100644 index 0000000..2112505 --- /dev/null +++ b/app/(routes)/recommendations/RecommendationsClient.integration.test.tsx @@ -0,0 +1,204 @@ +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { useRouter } from "next/navigation"; +import { useEffect, useState } from "react"; +import RecommendationsClient from "./RecommendationsClient"; +import { MovieProvider, useMovieContext } from "@/contexts/MovieContext"; +import { + createJsonResponse, + createRecommendationStreamResponse, + createTextStreamResponse, + recommendationFixtures, + tmdbFixtures, +} from "../../../tests/support/movie-test-fixtures"; + +jest.mock("next/navigation", () => ({ + useRouter: jest.fn(), +})); + +const mockReplace = jest.fn(); +const originalFetch = global.fetch; + +const seededParticipantsData = [ + { + favouriteMovie: "Inception", + movieType: "new" as const, + moodType: "fun" as const, + favouriteFilmPerson: "Keanu Reeves", + }, +]; + +function RecommendationsSessionInitialiser({ children }: { children: React.ReactNode }) { + const { setGroupTimeAvailable, setParticipantsData } = useMovieContext(); + const [isReady, setIsReady] = useState(false); + + useEffect(() => { + setParticipantsData(seededParticipantsData); + setGroupTimeAvailable("2 hours"); + setIsReady(true); + }, [setGroupTimeAvailable, setParticipantsData]); + + return isReady ? <>{children} : null; +} + +function MovieSessionSnapshot() { + const { participantsData, timeAvailable } = useMovieContext(); + + return ( + + {JSON.stringify({ + participantsCount: participantsData.length, + timeAvailable, + })} + + ); +} + +function renderRecommendations() { + return render( + + + + + + + ); +} + +function getRequestUrl(input: RequestInfo | URL): string { + if (typeof input === "string") return input; + if (input instanceof URL) return input.toString(); + return input.url; +} + +describe("RecommendationsClient integration", () => { + beforeEach(() => { + jest.clearAllMocks(); + (useRouter as jest.Mock).mockReturnValue({ replace: mockReplace }); + }); + + afterEach(() => { + global.fetch = originalFetch; + }); + + it("shows a loading state before rendering streamed recommendations", async () => { + let resolveRecommendationResponse: ((response: Response) => void) | undefined; + + global.fetch = jest.fn((input: RequestInfo | URL) => { + const url = getRequestUrl(input); + + if (url === "/api/recommendations") { + return new Promise((resolve) => { + resolveRecommendationResponse = resolve; + }); + } + + if (url.startsWith("https://api.themoviedb.org/")) { + return Promise.resolve( + createJsonResponse(tmdbFixtures.postersByQuery["Mad Max: Fury Road"]) + ); + } + + return Promise.reject(new Error(`Unhandled fetch: ${url}`)); + }) as typeof fetch; + + renderRecommendations(); + + await waitFor(() => { + expect(screen.getByText("Generating recommendations...")).toBeInTheDocument(); + }); + + await act(async () => { + resolveRecommendationResponse?.(createRecommendationStreamResponse()); + }); + + await waitFor(() => { + expect( + screen.getByRole("heading", { name: "Mad Max: Fury Road (2015)" }) + ).toBeInTheDocument(); + }); + + expect(screen.getByText("Movie 1 of 2")).toBeInTheDocument(); + expect(screen.getByAltText("Mad Max: Fury Road")).toBeInTheDocument(); + }); + + it("renders a no-results state when the streamed response is empty", async () => { + global.fetch = jest.fn((input: RequestInfo | URL) => { + const url = getRequestUrl(input); + + if (url === "/api/recommendations") { + return Promise.resolve(createRecommendationStreamResponse(recommendationFixtures.empty)); + } + + return Promise.reject(new Error(`Unhandled fetch: ${url}`)); + }) as typeof fetch; + + renderRecommendations(); + + await waitFor(() => { + expect(screen.getByText("No strong matches this time")).toBeInTheDocument(); + }); + }); + + it("renders the streaming error state when the recommendations request fails", async () => { + global.fetch = jest.fn((input: RequestInfo | URL) => { + const url = getRequestUrl(input); + + if (url === "/api/recommendations") { + return Promise.resolve( + createTextStreamResponse("Pipeline failed", { + status: 500, + headers: { + "Content-Type": "text/plain; charset=utf-8", + }, + }) + ); + } + + return Promise.reject(new Error(`Unhandled fetch: ${url}`)); + }) as typeof fetch; + + renderRecommendations(); + + await waitFor(() => { + expect(screen.getByText("Oops! Something went wrong")).toBeInTheDocument(); + }); + + expect(screen.getByText(/Pipeline failed/)).toBeInTheDocument(); + }); + + it("clears the movie session and routes home when Start Over is pressed", async () => { + global.fetch = jest.fn((input: RequestInfo | URL) => { + const url = getRequestUrl(input); + + if (url === "/api/recommendations") { + return Promise.resolve(createRecommendationStreamResponse()); + } + + if (url.startsWith("https://api.themoviedb.org/")) { + return Promise.resolve( + createJsonResponse(tmdbFixtures.postersByQuery["Mad Max: Fury Road"]) + ); + } + + return Promise.reject(new Error(`Unhandled fetch: ${url}`)); + }) as typeof fetch; + + renderRecommendations(); + + await waitFor(() => { + expect( + screen.getByRole("heading", { name: "Mad Max: Fury Road (2015)" }) + ).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByRole("button", { name: "Start Over" })); + + expect(mockReplace).toHaveBeenCalledWith("/"); + + await waitFor(() => { + expect(screen.getByTestId("session-state")).toHaveTextContent('"participantsCount":0'); + }); + + expect(screen.getByTestId("session-state")).toHaveTextContent('"timeAvailable":""'); + }); +}); diff --git a/app/(routes)/recommendations/RecommendationsClient.tsx b/app/(routes)/recommendations/RecommendationsClient.tsx index a0f0aaa..3de6eaa 100644 --- a/app/(routes)/recommendations/RecommendationsClient.tsx +++ b/app/(routes)/recommendations/RecommendationsClient.tsx @@ -189,12 +189,12 @@ export default function RecommendationsClient() { {hasRenderableCurrentMovie && ( <> -
+

{currentMovieName} {currentMovieReleaseYear ? `(${currentMovieReleaseYear})` : ""} {isLoading && currentIndex === recommendedMovies.length - 1 && ( )} -

+ {isLoadingPoster ? (
({ + streamMovieRecommendations: jest.fn(), +})); + +import { streamMovieRecommendations } from "@/lib/services/movie-recommendations"; + +describe("POST /api/recommendations integration", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("returns the streamed text response body on success", async () => { + const payload = { + participantsData: [ + { + favouriteMovie: "Inception", + movieType: "new", + moodType: "fun", + favouriteFilmPerson: "Keanu Reeves", + }, + ], + timeAvailable: "2 hours", + }; + + (streamMovieRecommendations as jest.Mock).mockResolvedValue({ + toTextStreamResponse: () => + createRecommendationStreamResponse(recommendationFixtures.groupJourney), + }); + + const response = await POST( + new Request("http://localhost:3000/api/recommendations", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(payload), + }) + ); + + expect(response.status).toBe(200); + expect(await response.text()).toBe(JSON.stringify(recommendationFixtures.groupJourney)); + expect(streamMovieRecommendations).toHaveBeenCalledWith(payload); + }); + + it("returns an empty recommendation payload when the pipeline finds no matches", async () => { + (streamMovieRecommendations as jest.Mock).mockResolvedValue(null); + + const response = await POST( + new Request("http://localhost:3000/api/recommendations", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + participantsData: [], + timeAvailable: "90 minutes", + }), + }) + ); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ recommendedMovies: [] }); + }); + + it("returns a 500 JSON payload when the pipeline throws", async () => { + (streamMovieRecommendations as jest.Mock).mockRejectedValue(new Error("Pipeline failed")); + + const response = await POST( + new Request("http://localhost:3000/api/recommendations", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + participantsData: [], + timeAvailable: "90 minutes", + }), + }) + ); + + expect(response.status).toBe(500); + await expect(response.json()).resolves.toEqual({ error: "Pipeline failed" }); + }); +}); diff --git a/components/features/ParticipantsSetup.tsx b/components/features/ParticipantsSetup.tsx index a45edf9..3228a61 100644 --- a/components/features/ParticipantsSetup.tsx +++ b/components/features/ParticipantsSetup.tsx @@ -50,7 +50,14 @@ const ParticipantsSetup = () => {
+ setGroupTimeAvailable(e.target.value)} diff --git a/jest.config.js b/jest.config.js index af0cc13..4876f25 100644 --- a/jest.config.js +++ b/jest.config.js @@ -9,6 +9,7 @@ const createJestConfig = nextJest({ const customJestConfig = { setupFilesAfterEnv: ["/jest.setup.js"], testEnvironment: "jsdom", + testPathIgnorePatterns: ["/node_modules/", "/.next/", "/tests/e2e/"], moduleNameMapper: { "^@/services/(.*)$": "/lib/services/$1", "^@/config/(.*)$": "/lib/config/$1", @@ -30,6 +31,9 @@ const customJestConfig = { "!tailwind.config.ts", "!app/fonts.ts", "!app/layout.tsx", + "!playwright.config.ts", + "!tests/e2e/**", + "!tests/support/**", ], coverageThreshold: { global: { diff --git a/jest.setup.js b/jest.setup.js index b701334..b1eee13 100644 --- a/jest.setup.js +++ b/jest.setup.js @@ -1,5 +1,18 @@ // Learn more: https://github.com/testing-library/jest-dom import "@testing-library/jest-dom"; -import { TransformStream } from "node:stream/web"; +import { TextDecoder, TextEncoder } from "node:util"; +import { + ReadableStream, + TextDecoderStream, + TransformStream, + WritableStream, +} from "node:stream/web"; -Object.assign(global, { TransformStream }); +Object.assign(global, { + ReadableStream, + TextDecoder, + TextDecoderStream, + TextEncoder, + TransformStream, + WritableStream, +}); diff --git a/next.config.ts b/next.config.ts index 83e27bb..e3d929b 100644 --- a/next.config.ts +++ b/next.config.ts @@ -3,6 +3,7 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { reactCompiler: true, + allowedDevOrigins: ["127.0.0.1"], images: { remotePatterns: [ { diff --git a/package.json b/package.json index 7e62847..178382f 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,8 @@ "type-check": "tsc --noEmit", "test": "jest", "test:ci": "jest --ci", + "test:e2e": "playwright test", + "test:integration": "jest --runInBand --testPathPattern='\\.integration\\.test\\.(ts|tsx)$'", "test:coverage": "jest --coverage", "validate:push": "pnpm type-check && pnpm lint && pnpm test:coverage", "prepare": "husky" @@ -38,6 +40,7 @@ "@cloudflare/workers-types": "^4.20241224.0", "@eslint/eslintrc": "^3", "@next/bundle-analyzer": "16.2.1", + "@playwright/test": "^1.59.0", "@testing-library/jest-dom": "6.1.5", "@testing-library/react": "^16", "@types/jest": "29.5.11", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..b0352eb --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,33 @@ +import { defineConfig, devices } from "@playwright/test"; + +const baseURL = "http://127.0.0.1:3000"; + +export default defineConfig({ + testDir: "./tests/e2e", + fullyParallel: true, + forbidOnly: Boolean(process.env.CI), + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + outputDir: "output/playwright/test-results", + reporter: [["list"], ["html", { open: "never", outputFolder: "output/playwright/report" }]], + use: { + baseURL, + trace: "on-first-retry", + screenshot: "only-on-failure", + video: "retain-on-failure", + }, + projects: [ + { + name: "chromium", + use: { + ...devices["Desktop Chrome"], + }, + }, + ], + webServer: { + command: process.env.CI ? "pnpm start" : "pnpm build && pnpm start", + url: baseURL, + reuseExistingServer: !process.env.CI, + timeout: 180_000, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b2a87e4..10fb0d4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -31,7 +31,7 @@ importers: version: 1.7.0(react@19.2.4) next: specifier: 16.2.1 - version: 16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + version: 16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.59.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) react: specifier: ^19.2.4 version: 19.2.4 @@ -51,6 +51,9 @@ importers: '@next/bundle-analyzer': specifier: 16.2.1 version: 16.2.1 + '@playwright/test': + specifier: ^1.59.0 + version: 1.59.0 '@testing-library/jest-dom': specifier: 6.1.5 version: 6.1.5(@jest/globals@29.7.0)(@types/jest@29.5.11)(jest@29.7.0(@types/node@22.19.15)) @@ -1012,6 +1015,11 @@ packages: resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==} engines: {node: '>=8.0.0'} + '@playwright/test@1.59.0': + resolution: {integrity: sha512-TOA5sTLd49rTDaZpYpvCQ9hGefHQq/OYOyCVnGqS2mjMfX+lGZv2iddIJd0I48cfxqSPttS9S3OuLKyylHcO1w==} + engines: {node: '>=18'} + hasBin: true + '@polka/url@1.0.0-next.29': resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} @@ -2197,6 +2205,11 @@ packages: fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -3109,6 +3122,16 @@ packages: resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==} engines: {node: '>=8'} + playwright-core@1.59.0: + resolution: {integrity: sha512-PW/X/IoZ6BMUUy8rpwHEZ8Kc0IiLIkgKYGNFaMs5KmQhcfLILNx9yCQD0rnWeWfz1PNeqcFP1BsihQhDOBCwZw==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.59.0: + resolution: {integrity: sha512-wihGScriusvATUxmhfENxg0tj1vHEFeIwxlnPFKQTOQVd7aG08mUfvvniRP/PtQOC+2Bs52kBOC/Up1jTXeIbw==} + engines: {node: '>=18'} + hasBin: true + possible-typed-array-names@1.1.0: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} @@ -4756,6 +4779,10 @@ snapshots: '@opentelemetry/api@1.9.0': {} + '@playwright/test@1.59.0': + dependencies: + playwright: 1.59.0 + '@polka/url@1.0.0-next.29': {} '@poppinss/colors@4.1.6': @@ -6192,6 +6219,9 @@ snapshots: fs.realpath@1.0.0: {} + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -7149,7 +7179,7 @@ snapshots: natural-compare@1.4.0: {} - next@16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + next@16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.59.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: '@next/env': 16.2.1 '@swc/helpers': 0.5.15 @@ -7169,6 +7199,7 @@ snapshots: '@next/swc-win32-arm64-msvc': 16.2.1 '@next/swc-win32-x64-msvc': 16.2.1 '@opentelemetry/api': 1.9.0 + '@playwright/test': 1.59.0 babel-plugin-react-compiler: 1.0.0 sharp: 0.34.5 transitivePeerDependencies: @@ -7336,6 +7367,14 @@ snapshots: dependencies: find-up: 4.1.0 + playwright-core@1.59.0: {} + + playwright@1.59.0: + dependencies: + playwright-core: 1.59.0 + optionalDependencies: + fsevents: 2.3.2 + possible-typed-array-names@1.1.0: {} postcss-import@15.1.0(postcss@8.5.8): diff --git a/tests/e2e/guard-and-validation.spec.ts b/tests/e2e/guard-and-validation.spec.ts new file mode 100644 index 0000000..608e008 --- /dev/null +++ b/tests/e2e/guard-and-validation.spec.ts @@ -0,0 +1,25 @@ +import { expect, test } from "@playwright/test"; + +test.describe("Recommendation guardrails", () => { + test("shows validation feedback when the movie form is submitted empty", async ({ page }) => { + await page.goto("/"); + await page.getByRole("button", { name: "Start" }).click(); + + await expect(page).toHaveURL(/\/movieForm$/); + + await page.getByRole("button", { name: "Get Movie" }).click(); + + await expect(page.getByText("Please fill out all required fields")).toBeVisible(); + await expect(page.getByText("This field is required")).toHaveCount(2); + await expect(page).toHaveURL(/\/movieForm$/); + }); + + test("redirects back home when recommendations are opened without session state", async ({ + page, + }) => { + await page.goto("/recommendations"); + + await expect(page).toHaveURL(/\/$/); + await expect(page.getByRole("button", { name: "Start" })).toBeVisible(); + }); +}); diff --git a/tests/e2e/happy-path.spec.ts b/tests/e2e/happy-path.spec.ts new file mode 100644 index 0000000..be1a04a --- /dev/null +++ b/tests/e2e/happy-path.spec.ts @@ -0,0 +1,62 @@ +import { expect, test } from "@playwright/test"; +import { stubMovieApis } from "./support/network"; + +test.describe("Core recommendation journey", () => { + test("takes a two-person group from setup to recommendations and back home", async ({ page }) => { + await stubMovieApis(page); + + await page.goto("/"); + + const participantsSlider = page.getByRole("slider", { name: "How many people?" }); + await participantsSlider.focus(); + await participantsSlider.press("ArrowRight"); + + await page.getByLabel("How much time do you have?").fill("2 hours"); + await page.getByRole("button", { name: "Start" }).click(); + + await expect(page).toHaveURL(/\/movieForm$/); + await expect(page.getByRole("heading", { name: "Person #1" })).toBeVisible(); + + await page + .getByLabel(/what's your favourite movie and why/i) + .fill("I love practical action movies with huge set pieces and momentum."); + await page.getByRole("tab", { name: "Classic" }).click(); + await page.getByRole("tab", { name: "Serious" }).click(); + await page + .getByLabel(/which famous film person would you love to be stranded on an island with/i) + .fill("George Miller for the stories."); + await page.getByRole("button", { name: /^Next$/ }).click(); + + await expect(page.getByRole("heading", { name: "Person #2" })).toBeVisible(); + await expect(page.getByLabel(/what's your favourite movie and why/i)).toHaveValue(""); + + await page + .getByLabel(/what's your favourite movie and why/i) + .fill("I want something warm, funny, and comforting for a relaxed night."); + await page + .getByLabel(/which famous film person would you love to be stranded on an island with/i) + .fill("Paddington, obviously."); + + const recommendationsRequest = page.waitForRequest("**/api/recommendations"); + const recommendationsResponse = page.waitForResponse("**/api/recommendations"); + await page.getByRole("button", { name: "Get Movie" }).click(); + + await expect(page).toHaveURL(/\/recommendations$/); + await recommendationsRequest; + await recommendationsResponse; + await expect(page.getByRole("heading", { name: "Mad Max: Fury Road (2015)" })).toBeVisible({ + timeout: 15_000, + }); + await expect(page.getByRole("img", { name: "Mad Max: Fury Road" })).toBeVisible(); + + await page.getByRole("button", { name: "Next Movie" }).click(); + await expect(page.getByRole("heading", { name: "Paddington 2 (2017)" })).toBeVisible(); + await expect(page.getByRole("img", { name: "Paddington 2" })).toBeVisible(); + + await page.getByRole("button", { name: "Start Over" }).click(); + + await expect(page).toHaveURL(/\/$/); + await expect(page.getByRole("button", { name: "Start" })).toBeVisible(); + await expect(page.getByLabel("How much time do you have?")).toHaveValue(""); + }); +}); diff --git a/tests/e2e/support/network.ts b/tests/e2e/support/network.ts new file mode 100644 index 0000000..6f1d2dd --- /dev/null +++ b/tests/e2e/support/network.ts @@ -0,0 +1,73 @@ +import type { Page } from "@playwright/test"; +import { + createRecommendationTextBody, + recommendationFixtures, + tmdbFixtures, + type RecommendationFixture, + type TmdbSearchResponse, +} from "../../support/movie-test-fixtures"; + +type StubMovieApisOptions = { + recommendations?: RecommendationFixture; + recommendationsStatus?: number; + tmdbResponsesByQuery?: Record; +}; + +const ONE_PIXEL_PNG = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9sN0xG4AAAAASUVORK5CYII=", + "base64" +); + +export async function stubMovieApis( + page: Page, + { + recommendations = recommendationFixtures.groupJourney, + recommendationsStatus = 200, + tmdbResponsesByQuery = tmdbFixtures.postersByQuery, + }: StubMovieApisOptions = {} +) { + await page.route("**/api/recommendations", async (route) => { + if (recommendationsStatus >= 400) { + await route.fulfill({ + status: recommendationsStatus, + contentType: "text/plain; charset=utf-8", + body: "Pipeline failed", + }); + return; + } + + await route.fulfill({ + status: 200, + contentType: "text/plain; charset=utf-8", + body: createRecommendationTextBody(recommendations), + }); + }); + + await page.route("https://api.themoviedb.org/**", async (route) => { + const requestUrl = new URL(route.request().url()); + const query = requestUrl.searchParams.get("query") ?? ""; + const body = tmdbResponsesByQuery[query] ?? tmdbFixtures.empty; + + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(body), + }); + }); + + await page.route("**/_next/image**", async (route) => { + await route.fulfill({ + status: 200, + contentType: "image/png", + body: ONE_PIXEL_PNG, + }); + }); + + await page.route("https://image.tmdb.org/**", async (route) => { + await route.fulfill({ + status: 200, + contentType: "image/png", + body: ONE_PIXEL_PNG, + }); + }); +} diff --git a/tests/support/movie-test-fixtures.ts b/tests/support/movie-test-fixtures.ts new file mode 100644 index 0000000..908bf08 --- /dev/null +++ b/tests/support/movie-test-fixtures.ts @@ -0,0 +1,102 @@ +export const recommendationFixtures = { + groupJourney: { + recommendedMovies: [ + { + name: "Mad Max: Fury Road", + releaseYear: "2015", + synopsis: "A relentless desert chase with furious stunts and a huge heart.", + }, + { + name: "Paddington 2", + releaseYear: "2017", + synopsis: "Paddington brings warmth, chaos, and kindness to a city mystery.", + }, + ], + }, + empty: { + recommendedMovies: [], + }, +} as const; + +export type RecommendationFixture = + (typeof recommendationFixtures)[keyof typeof recommendationFixtures]; + +export function createTmdbSearchResponse(posterPath?: string) { + return { + results: posterPath ? [{ poster_path: posterPath }] : [], + }; +} + +export const tmdbFixtures = { + postersByQuery: { + "Mad Max: Fury Road": createTmdbSearchResponse("/mad-max-fury-road.jpg"), + "Paddington 2": createTmdbSearchResponse("/paddington-2.jpg"), + }, + empty: createTmdbSearchResponse(), +} as const; + +export type TmdbSearchResponse = ReturnType; + +function createTextStream(text: string, chunkSize = text.length): ReadableStream { + const encoder = new TextEncoder(); + + return new ReadableStream({ + start(controller) { + for (let index = 0; index < text.length; index += chunkSize) { + controller.enqueue(encoder.encode(text.slice(index, index + chunkSize))); + } + + controller.close(); + }, + }); +} + +export function createRecommendationTextBody( + payload: RecommendationFixture = recommendationFixtures.groupJourney +): string { + return JSON.stringify(payload); +} + +export function createTextStreamResponse(text: string, init: ResponseInit = {}): Response { + const headers = new Headers(init.headers); + const status = init.status ?? 200; + + if (!headers.has("Content-Type")) { + headers.set("Content-Type", "text/plain; charset=utf-8"); + } + + return { + ok: status >= 200 && status < 300, + status, + headers, + body: createTextStream(text), + text: async () => text, + json: async () => JSON.parse(text), + } as Response; +} + +export function createJsonResponse(payload: unknown, init: ResponseInit = {}): Response { + const text = JSON.stringify(payload); + const headers = new Headers(init.headers); + const status = init.status ?? 200; + + if (!headers.has("Content-Type")) { + headers.set("Content-Type", "application/json"); + } + + return { + ok: status >= 200 && status < 300, + status, + headers, + body: createTextStream(text), + text: async () => text, + json: async () => payload, + } as Response; +} + +export function createRecommendationStreamResponse( + payload: RecommendationFixture = recommendationFixtures.groupJourney, + init: ResponseInit = {} +): Response { + return createTextStreamResponse(createRecommendationTextBody(payload), init); +}