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 .env.test
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
NEXT_PUBLIC_SUPABASE_URL=http://127.0.0.1:54321
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0
SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU
CRON_SECRET=test-cron-secret
NEXT_PUBLIC_APP_URL=http://localhost:3100
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ build/
test-results/
playwright-report/
blob-report/
e2e/.auth/
e2e/.test-data.json

# env files
.env*.local
Expand All @@ -23,3 +25,4 @@ next-env.d.ts

# local settings
.claude/settings.local.json
.vercel
15 changes: 15 additions & 0 deletions e2e/auth.setup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { test as setup, expect } from "@playwright/test";

setup("authenticate", async ({ page }) => {
await page.goto("/login");
await page.getByPlaceholder("you@example.com").fill("e2e-test@example.com");
await page.locator('input[type="password"]').fill("test-password-123");
await page.getByRole("button", { name: "Log in" }).click();

// Wait for redirect to home page
await page.waitForURL("/");
await expect(page.getByRole("link", { name: "Settings" })).toBeVisible();

// Save auth state
await page.context().storageState({ path: "e2e/.auth/user.json" });
});
62 changes: 62 additions & 0 deletions e2e/auth.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { test, expect } from "@playwright/test";

test.use({ storageState: { cookies: [], origins: [] } });

test("shows login form with email and password fields", async ({ page }) => {
await page.goto("/login");
await expect(page.getByRole("heading", { name: "Log in" })).toBeVisible();
await expect(page.getByPlaceholder("you@example.com")).toBeVisible();
await expect(page.locator('input[type="password"]')).toBeVisible();
await expect(page.getByRole("button", { name: "Log in" })).toBeVisible();
});

test("shows error on invalid credentials", async ({ page }) => {
await page.goto("/login");
await page.getByPlaceholder("you@example.com").fill("wrong@example.com");
await page.locator('input[type="password"]').fill("wrong-password");
await page.getByRole("button", { name: "Log in" }).click();

await expect(page.getByText("Invalid email or password")).toBeVisible();
});

test("logs in successfully and shows Settings and Log out", async ({ page }) => {
await page.goto("/login");
await page.getByPlaceholder("you@example.com").fill("e2e-test@example.com");
await page.locator('input[type="password"]').fill("test-password-123");
await page.getByRole("button", { name: "Log in" }).click();

await page.waitForURL("/");
await expect(page.getByRole("link", { name: "Settings" })).toBeVisible();
await expect(page.getByRole("button", { name: "Log out" })).toBeVisible();
});

test("redirects unauthenticated user from /settings to /login", async ({ page }) => {
await page.goto("/settings");
await page.waitForURL(/\/login/);
await expect(page.getByRole("heading", { name: "Log in" })).toBeVisible();
});

test("logout redirects to /login", async ({ page, browser }) => {
// First log in
await page.goto("/login");
await page.getByPlaceholder("you@example.com").fill("e2e-test@example.com");
await page.locator('input[type="password"]').fill("test-password-123");
await page.getByRole("button", { name: "Log in" }).click();
await page.waitForURL("/");

// Then log out (this invalidates ALL sessions via Supabase global signOut)
await page.getByRole("button", { name: "Log out" }).click();
await page.waitForURL(/\/login/);
await expect(page.getByRole("link", { name: "Log in" })).toBeVisible();

// Re-authenticate and re-save storageState so subsequent tests still work
const context = await browser.newContext();
const freshPage = await context.newPage();
await freshPage.goto("/login");
await freshPage.getByPlaceholder("you@example.com").fill("e2e-test@example.com");
await freshPage.locator('input[type="password"]').fill("test-password-123");
await freshPage.getByRole("button", { name: "Log in" }).click();
await freshPage.waitForURL("/");
await context.storageState({ path: "e2e/.auth/user.json" });
await context.close();
});
36 changes: 36 additions & 0 deletions e2e/contacts.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { test, expect } from "./fixtures";

test.afterEach(async ({ adminClient }) => {
await adminClient.from("contacts").delete().neq("id", "00000000-0000-0000-0000-000000000000");
});

test("shows Contacts heading on settings page", async ({ page }) => {
await page.goto("/settings");
await expect(page.getByRole("heading", { name: "Contacts" })).toBeVisible();
});

test("shows empty state", async ({ page }) => {
await page.goto("/settings");
await expect(page.getByText("No contacts added yet.")).toBeVisible();
});

test("adds a contact", async ({ page }) => {
await page.goto("/settings");
await page.getByPlaceholder("email@example.com").fill("contact@example.com");
await page.getByRole("button", { name: "Add" }).click();

await expect(page.getByText("contact@example.com")).toBeVisible();
});

test("removes a contact", async ({ page, adminClient }) => {
// Seed a contact
await adminClient.from("contacts").insert({ email: "remove-me@example.com" });

await page.goto("/settings");
await expect(page.getByText("remove-me@example.com")).toBeVisible();

// Click the Remove button next to the specific contact
const contactRow = page.getByRole("listitem").filter({ hasText: "remove-me@example.com" });
await contactRow.getByRole("button", { name: "Remove" }).click();
await expect(page.getByText("remove-me@example.com")).not.toBeVisible();
});
32 changes: 32 additions & 0 deletions e2e/fixtures.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { test as base, expect } from "@playwright/test";
import { createClient, SupabaseClient } from "@supabase/supabase-js";
import fs from "fs";
import path from "path";

type TestData = {
userId: string;
siteId: string;
};

type Fixtures = {
adminClient: SupabaseClient;
testData: TestData;
};

export const test = base.extend<Fixtures>({
adminClient: async ({}, use) => {
const client = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!
);
await use(client);
},
testData: async ({}, use) => {
const data = JSON.parse(
fs.readFileSync(path.resolve(__dirname, ".test-data.json"), "utf-8")
);
await use(data);
},
});

export { expect };
52 changes: 52 additions & 0 deletions e2e/global-setup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { createClient } from "@supabase/supabase-js";
import fs from "fs";
import path from "path";

export default async function globalSetup() {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;

const admin = createClient(supabaseUrl, serviceRoleKey);

// Clean all tables
await admin.from("incidents").delete().neq("id", "00000000-0000-0000-0000-000000000000");
await admin.from("checks").delete().neq("id", "00000000-0000-0000-0000-000000000000");
await admin.from("contacts").delete().neq("id", "00000000-0000-0000-0000-000000000000");
await admin.from("sites").delete().neq("id", "00000000-0000-0000-0000-000000000000");

// Delete existing test user if present
const { data: existingUsers } = await admin.auth.admin.listUsers();
const existingUser = existingUsers?.users?.find(
(u) => u.email === "e2e-test@example.com"
);
if (existingUser) {
await admin.auth.admin.deleteUser(existingUser.id);
}

// Create test user
const { data: userData, error: userError } =
await admin.auth.admin.createUser({
email: "e2e-test@example.com",
password: "test-password-123",
email_confirm: true,
});
if (userError) throw new Error(`Failed to create test user: ${userError.message}`);

// Seed one baseline site
const { data: siteData, error: siteError } = await admin
.from("sites")
.insert({ name: "E2E Test Site", url: "https://httpbin.org/status/200" })
.select()
.single();
if (siteError) throw new Error(`Failed to seed site: ${siteError.message}`);

// Write test data for specs to consume
const testData = {
userId: userData.user.id,
siteId: siteData.id,
};
fs.writeFileSync(
path.resolve(__dirname, ".test-data.json"),
JSON.stringify(testData, null, 2)
);
}
34 changes: 34 additions & 0 deletions e2e/global-teardown.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { createClient } from "@supabase/supabase-js";
import fs from "fs";
import path from "path";

export default async function globalTeardown() {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;

const admin = createClient(supabaseUrl, serviceRoleKey);

// Clean all tables
await admin.from("incidents").delete().neq("id", "00000000-0000-0000-0000-000000000000");
await admin.from("checks").delete().neq("id", "00000000-0000-0000-0000-000000000000");
await admin.from("contacts").delete().neq("id", "00000000-0000-0000-0000-000000000000");
await admin.from("sites").delete().neq("id", "00000000-0000-0000-0000-000000000000");

// Delete test user
const { data: existingUsers } = await admin.auth.admin.listUsers();
const testUser = existingUsers?.users?.find(
(u) => u.email === "e2e-test@example.com"
);
if (testUser) {
await admin.auth.admin.deleteUser(testUser.id);
}

// Clean up generated files
const testDataPath = path.resolve(__dirname, ".test-data.json");
if (fs.existsSync(testDataPath)) fs.unlinkSync(testDataPath);

const authDir = path.resolve(__dirname, ".auth");
if (fs.existsSync(authDir)) {
fs.rmSync(authDir, { recursive: true });
}
}
6 changes: 0 additions & 6 deletions e2e/home.spec.ts

This file was deleted.

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

// Each test seeds its own incident, so they can't run in parallel within this file
test.describe.configure({ mode: "serial" });

let incidentId: string;

test.beforeEach(async ({ adminClient, testData }) => {
// Clean up any previous incidents/checks
await adminClient.from("incidents").delete().eq("site_id", testData.siteId);
await adminClient.from("checks").delete().eq("site_id", testData.siteId);

// Insert a failing check
const { data: check } = await adminClient
.from("checks")
.insert({
site_id: testData.siteId,
status: "failure",
error: "Connection timeout",
checked_at: new Date().toISOString(),
})
.select()
.single();

// Insert an open incident
const { data: incident } = await adminClient
.from("incidents")
.insert({
site_id: testData.siteId,
check_id: check!.id,
status: "open",
opened_at: new Date().toISOString(),
})
.select()
.single();
incidentId = incident!.id;
});

test.afterEach(async ({ adminClient, testData }) => {
await adminClient.from("incidents").delete().eq("site_id", testData.siteId);
await adminClient.from("checks").delete().eq("site_id", testData.siteId);
});

test("shows Open Incidents section on status page", async ({ page }) => {
await page.goto("/");
await expect(page.getByRole("heading", { name: "Open Incidents" })).toBeVisible();
// Site name appears in the incident card within Open Incidents section
const incidentsSection = page.locator("section").filter({
has: page.getByRole("heading", { name: "Open Incidents" }),
});
await expect(incidentsSection.getByText("E2E Test Site")).toBeVisible();
});

test("navigates to incident detail", async ({ page }) => {
await page.goto("/");
await page.locator(`a[href="/incidents/${incidentId}"]`).click();
await expect(page.getByRole("heading", { name: "Incident on E2E Test Site" })).toBeVisible();
});

test("shows Open badge and Resolve button", async ({ page }) => {
await page.goto(`/incidents/${incidentId}`);
await expect(page.getByText("Open", { exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "Resolve" })).toBeVisible();
});

test("resolves an incident", async ({ page }) => {
await page.goto(`/incidents/${incidentId}`);
await page.getByRole("button", { name: "Resolve" }).click();

await expect(page.getByText("Resolved", { exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "Resolve" })).not.toBeVisible();
});

test("resolved incident no longer appears in Open Incidents", async ({ page }) => {
await page.goto(`/incidents/${incidentId}`);
await page.getByRole("button", { name: "Resolve" }).click();
await expect(page.getByText("Resolved", { exact: true })).toBeVisible();

await page.goto("/");
await expect(page.getByRole("heading", { name: "Open Incidents" })).not.toBeVisible();
});
Loading