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
41 changes: 41 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

# testing
/coverage
/output/playwright/

# next.js
/.next/
Expand Down Expand Up @@ -59,4 +60,4 @@ terminal-commands.md
.cursor
skills/
.mcp.json
skills-lock.json
skills-lock.json
27 changes: 22 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
```
Expand Down
121 changes: 121 additions & 0 deletions app/(routes)/movieForm/MovieFormClient.integration.test.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<>
<output data-testid="participants-count">{participantsData.length}</output>
<output data-testid="participants-json">{JSON.stringify(participantsData)}</output>
<output data-testid="total-participants">{totalParticipants}</output>
</>
);
}

function renderMovieForm(totalParticipants = 2) {
return render(
<MovieProvider>
<MovieFormSessionInitialiser totalParticipants={totalParticipants}>
<MovieFormClient />
<MovieContextSnapshot />
</MovieFormSessionInitialiser>
</MovieProvider>
);
}

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."
);
});
});
Loading
Loading