diff --git a/.env.test b/.env.test new file mode 100644 index 0000000..be4730d --- /dev/null +++ b/.env.test @@ -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 diff --git a/.gitignore b/.gitignore index 2382d74..83d5462 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,8 @@ build/ test-results/ playwright-report/ blob-report/ +e2e/.auth/ +e2e/.test-data.json # env files .env*.local @@ -23,3 +25,4 @@ next-env.d.ts # local settings .claude/settings.local.json +.vercel diff --git a/e2e/auth.setup.ts b/e2e/auth.setup.ts new file mode 100644 index 0000000..3f6ad13 --- /dev/null +++ b/e2e/auth.setup.ts @@ -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" }); +}); diff --git a/e2e/auth.spec.ts b/e2e/auth.spec.ts new file mode 100644 index 0000000..62c24b8 --- /dev/null +++ b/e2e/auth.spec.ts @@ -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(); +}); diff --git a/e2e/contacts.spec.ts b/e2e/contacts.spec.ts new file mode 100644 index 0000000..528b10e --- /dev/null +++ b/e2e/contacts.spec.ts @@ -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(); +}); diff --git a/e2e/fixtures.ts b/e2e/fixtures.ts new file mode 100644 index 0000000..c3d703b --- /dev/null +++ b/e2e/fixtures.ts @@ -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({ + 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 }; diff --git a/e2e/global-setup.ts b/e2e/global-setup.ts new file mode 100644 index 0000000..d0dc8da --- /dev/null +++ b/e2e/global-setup.ts @@ -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) + ); +} diff --git a/e2e/global-teardown.ts b/e2e/global-teardown.ts new file mode 100644 index 0000000..226eb46 --- /dev/null +++ b/e2e/global-teardown.ts @@ -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 }); + } +} diff --git a/e2e/home.spec.ts b/e2e/home.spec.ts deleted file mode 100644 index d1a7e89..0000000 --- a/e2e/home.spec.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { test, expect } from "@playwright/test"; - -test("homepage has Sites heading", async ({ page }) => { - await page.goto("/"); - await expect(page.getByRole("heading", { name: "Sites" })).toBeVisible(); -}); diff --git a/e2e/incidents.spec.ts b/e2e/incidents.spec.ts new file mode 100644 index 0000000..4a32235 --- /dev/null +++ b/e2e/incidents.spec.ts @@ -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(); +}); diff --git a/e2e/sites-crud.spec.ts b/e2e/sites-crud.spec.ts new file mode 100644 index 0000000..2ccb282 --- /dev/null +++ b/e2e/sites-crud.spec.ts @@ -0,0 +1,97 @@ +import { test, expect } from "./fixtures"; + +test.afterEach(async ({ adminClient }) => { + // Restore renamed site if needed + const { data: sites } = await adminClient.from("sites").select("id, name"); + const renamed = sites?.find((s) => s.name === "Renamed Site"); + if (renamed) { + await adminClient + .from("sites") + .update({ name: "E2E Test Site" }) + .eq("id", renamed.id); + } + + // Clean up any extra sites created during tests (keep the seeded one) + for (const site of sites ?? []) { + if (site.name !== "E2E Test Site" && site.name !== "Renamed Site") { + await adminClient.from("incidents").delete().eq("site_id", site.id); + await adminClient.from("checks").delete().eq("site_id", site.id); + await adminClient.from("sites").delete().eq("id", site.id); + } + } +}); + +test("shows Add site card when authenticated", async ({ page }) => { + await page.goto("/"); + await expect(page.getByText("Add site")).toBeVisible(); +}); + +test("adds a new site", async ({ page }) => { + await page.goto("/"); + await page.getByText("Add site").click(); + + await page.getByPlaceholder("Site name").fill("New Test Site"); + await page.getByPlaceholder("https://example.com").fill("https://example.com/test"); + await page.getByRole("button", { name: "Add" }).click(); + + await expect(page.getByText("New Test Site")).toBeVisible(); + await expect(page.getByText("https://example.com/test")).toBeVisible(); +}); + +test("cancels adding a site", async ({ page }) => { + await page.goto("/"); + await page.getByText("Add site").click(); + + await expect(page.getByPlaceholder("Site name")).toBeVisible(); + await page.getByRole("button", { name: "Cancel" }).click(); + + await expect(page.getByPlaceholder("Site name")).not.toBeVisible(); + await expect(page.getByText("Add site")).toBeVisible(); +}); + +test("edits a site name", async ({ page }) => { + await page.goto("/"); + + // Hover the site card to reveal the edit button + const siteCard = page.locator(".group").filter({ hasText: "E2E Test Site" }); + await siteCard.hover(); + await siteCard.getByTitle("Edit site").click({ force: true }); + + // Clear and fill the name field + const nameInput = page.locator('input[name="name"]'); + await nameInput.clear(); + await nameInput.fill("Renamed Site"); + await page.getByRole("button", { name: "Save" }).click(); + + await expect(page.getByText("Renamed Site")).toBeVisible(); +}); + +test("site detail Edit button opens edit form", async ({ page, testData }) => { + await page.goto(`/sites/${testData.siteId}`); + await page.getByTitle("Edit site").click(); + + // Should show the inline edit form with name and URL fields + await expect(page.locator('input[name="name"]')).toBeVisible(); + await expect(page.locator('input[name="url"]')).toBeVisible(); + await expect(page.getByRole("button", { name: "Save" })).toBeVisible(); +}); + +test("deletes a site", async ({ page, adminClient }) => { + // First create a site to delete + await adminClient + .from("sites") + .insert({ name: "Site To Delete", url: "https://example.com/delete" }); + + await page.goto("/"); + await expect(page.getByText("Site To Delete")).toBeVisible(); + + // Hover and click edit + const siteCard = page.locator(".group").filter({ hasText: "Site To Delete" }); + await siteCard.hover(); + await siteCard.getByTitle("Edit site").click({ force: true }); + + // Click delete + await page.getByRole("button", { name: "Delete" }).click(); + + await expect(page.getByText("Site To Delete")).not.toBeVisible(); +}); diff --git a/e2e/status-page.spec.ts b/e2e/status-page.spec.ts new file mode 100644 index 0000000..3e2fa29 --- /dev/null +++ b/e2e/status-page.spec.ts @@ -0,0 +1,35 @@ +import { test, expect } from "@playwright/test"; + +test.use({ storageState: { cookies: [], origins: [] } }); + +test("shows Sites heading", async ({ page }) => { + await page.goto("/"); + await expect(page.getByRole("heading", { name: "Sites" })).toBeVisible(); +}); + +test("shows seeded site card with name and URL", async ({ page }) => { + await page.goto("/"); + await expect(page.getByText("E2E Test Site")).toBeVisible(); + await expect(page.getByText("https://httpbin.org/status/200")).toBeVisible(); +}); + +test("does not show Add site button for unauthenticated user", async ({ page }) => { + await page.goto("/"); + await expect(page.getByText("Add site")).not.toBeVisible(); +}); + +test("navigates to site detail on card click", async ({ page }) => { + await page.goto("/"); + await page.getByRole("link", { name: /E2E Test Site/ }).click(); + await page.waitForURL(/\/sites\//, { timeout: 30_000 }); + await expect(page.getByRole("heading", { name: "E2E Test Site" })).toBeVisible(); +}); + +test("site detail shows expected sections", async ({ page }) => { + await page.goto("/"); + await page.getByRole("link", { name: /E2E Test Site/ }).click(); + + await expect(page.getByText("No incidents recorded.")).toBeVisible(); + await expect(page.getByText("No checks recorded yet.")).toBeVisible(); + await expect(page.getByText("← All Sites")).toBeVisible(); +}); diff --git a/package-lock.json b/package-lock.json index b2a8348..13d27c7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "@types/node": "^22", "@types/react": "^19", "@types/react-dom": "^19", + "env-cmd": "^11.0.0", "eslint": "^9", "eslint-config-next": "^15.2.0", "supabase": "^2.76.9", @@ -43,6 +44,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@commander-js/extra-typings": { + "version": "13.1.0", + "resolved": "https://registry.npmjs.org/@commander-js/extra-typings/-/extra-typings-13.1.0.tgz", + "integrity": "sha512-q5P52BYb1hwVWE6dtID7VvuJWrlfbCv4klj7BjUUOqMz4jbSZD4C9fJ9lRjL2jnBGTg+gDDlaXN51rkWcLk4fg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "commander": "~13.1.0" + } + }, "node_modules/@emnapi/core": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz", @@ -3463,6 +3474,16 @@ "dev": true, "license": "MIT" }, + "node_modules/commander": { + "version": "13.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz", + "integrity": "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -3696,6 +3717,24 @@ "node": ">=10.13.0" } }, + "node_modules/env-cmd": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/env-cmd/-/env-cmd-11.0.0.tgz", + "integrity": "sha512-gnG7H1PlwPqsGhFJNTv68lsDGyQdK+U9DwLVitcj1+wGq7LeOBgUzZd2puZ710bHcH9NfNeGWe2sbw7pdvAqDw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@commander-js/extra-typings": "^13.1.0", + "commander": "^13.1.0", + "cross-spawn": "^7.0.6" + }, + "bin": { + "env-cmd": "bin/env-cmd.js" + }, + "engines": { + "node": ">=20.10.0" + } + }, "node_modules/es-abstract": { "version": "1.24.1", "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.1.tgz", diff --git a/package.json b/package.json index 7bf2add..ab5ee8c 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "start": "next start", "lint": "next lint", "test": "vitest run", - "test:e2e": "playwright test" + "test:e2e": "env-cmd -f .env.test playwright test" }, "dependencies": { "@supabase/ssr": "^0.8.0", @@ -25,6 +25,7 @@ "@types/node": "^22", "@types/react": "^19", "@types/react-dom": "^19", + "env-cmd": "^11.0.0", "eslint": "^9", "eslint-config-next": "^15.2.0", "supabase": "^2.76.9", diff --git a/playwright.config.ts b/playwright.config.ts index 01d8a20..f2c0c45 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -5,23 +5,45 @@ const isCI = !!process.env.CI; export default defineConfig({ testDir: "./e2e", outputDir: isCI ? "/tmp/test-results" : "./test-results", - fullyParallel: true, + fullyParallel: false, forbidOnly: isCI, retries: isCI ? 2 : 0, - workers: isCI ? 1 : undefined, + workers: 1, reporter: "html", + expect: { + timeout: 10_000, + }, use: { baseURL: "http://localhost:3100", trace: "on-first-retry", }, + globalSetup: "./e2e/global-setup.ts", + globalTeardown: "./e2e/global-teardown.ts", projects: [ { - name: "chromium", + name: "setup", + testMatch: /auth\.setup\.ts/, + }, + { + // Public tests (no auth) — includes auth.spec which does logout/re-auth + name: "public", use: { ...devices["Desktop Chrome"] }, + dependencies: ["setup"], + testMatch: [/auth\.spec\.ts/, /status-page\.spec\.ts/], + }, + { + // Authenticated tests — run after public so auth.spec's logout + re-auth is done + name: "authenticated", + use: { + ...devices["Desktop Chrome"], + storageState: "e2e/.auth/user.json", + }, + dependencies: ["public"], + testMatch: [/contacts\.spec\.ts/, /sites-crud\.spec\.ts/, /incidents\.spec\.ts/], }, ], webServer: { - command: "npm run start -- -p 3100", + command: isCI ? "npm run start -- -p 3100" : "npm run dev -- -p 3100", url: "http://localhost:3100", reuseExistingServer: !isCI, }, diff --git a/src/app/sites/[id]/page.tsx b/src/app/sites/[id]/page.tsx index 0038490..532910e 100644 --- a/src/app/sites/[id]/page.tsx +++ b/src/app/sites/[id]/page.tsx @@ -3,6 +3,7 @@ import { notFound } from "next/navigation" import { createClient } from "@/lib/supabase/server" import type { Site, Check, Incident } from "@/lib/supabase/types" import { formatTimeAgo } from "@/lib/format" +import EditSiteButton from "@/components/EditSiteButton" export const revalidate = 0 @@ -92,13 +93,7 @@ export default async function SiteDetailPage({ {user && (
- - Edit - +
)}