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
17 changes: 17 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,20 @@ jobs:
- run: npm run lint
- run: npm run typecheck
- run: npm test

e2e:
runs-on: ubuntu-latest
env:
NEXT_PUBLIC_SUPABASE_URL: ${{ secrets.NEXT_PUBLIC_SUPABASE_URL }}
NEXT_PUBLIC_SUPABASE_ANON_KEY: ${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }}
NEXT_PUBLIC_CHAIN_ID: "8453"
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- run: npx playwright install --with-deps chromium
- run: npm run build
- run: npm run test:e2e
64 changes: 64 additions & 0 deletions e2e/create.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { test, expect } from "@playwright/test";

test.describe("Create Storyline Page", () => {
test("form renders with all fields", async ({ page }) => {
await page.goto("/create");

// Check for form fields — title, genre, language, genesis plot
// May show connect-wallet prompt instead if not connected
const titleInput = page.getByPlaceholder(/title/i).or(page.locator("input[name='title']"));
const hasForm = await titleInput.isVisible({ timeout: 5000 }).catch(() => false);
const hasConnectPrompt = await page.getByText(/connect/i).first().isVisible({ timeout: 3000 }).catch(() => false);

expect(hasForm || hasConnectPrompt).toBe(true);

if (hasForm) {
// Check for genre selector
const genreField = page.locator("select, [role='combobox'], button").filter({ hasText: /genre/i });
expect(await genreField.count()).toBeGreaterThan(0);

// Check for textarea (genesis plot)
const textarea = page.locator("textarea");
expect(await textarea.count()).toBeGreaterThan(0);
}
});

test("ruled paper styling on textareas", async ({ page }) => {
await page.goto("/create");

const textarea = page.locator("textarea").first();
if (await textarea.isVisible({ timeout: 5000 }).catch(() => false)) {
// Check for ruled-paper styling (class or inline style)
const ruledByStyle = page.locator("textarea[style*='repeating-linear-gradient'], [style*='repeating-linear-gradient']");
const ruledByClass = page.locator("[class*='ruled'], [class*='notebook'], [class*='paper']");
const hasRuled = (await ruledByStyle.count()) > 0 || (await ruledByClass.count()) > 0;
// Ruled paper styling may be on a parent wrapper, not the textarea itself
expect(hasRuled || await textarea.isVisible()).toBe(true);
}
});

test("empty title validation shows error", async ({ page }) => {
await page.goto("/create");

// Try submitting with empty title — look for submit button
const submitButton = page.locator("button[type='submit'], button").filter({ hasText: /create|submit|publish/i }).first();
if (await submitButton.isVisible({ timeout: 5000 }).catch(() => false)) {
await submitButton.click();
// Should show validation error or prevent submission
// Check for error message or required field indicator
const errorMsg = page.locator("[class*='error'], [role='alert'], .text-error, .text-red");
const hasError = (await errorMsg.count()) > 0;
const titleInput = page.getByPlaceholder(/title/i).first();
const isRequired = await titleInput.getAttribute("required");
expect(hasError || isRequired !== null).toBe(true);
}
});

test("wallet-not-connected state handled gracefully", async ({ page }) => {
await page.goto("/create");
await expect(page.locator("body")).toBeVisible();
// No unhandled error overlay
const errorOverlay = page.locator("#__next-build-error, [data-nextjs-dialog]");
await expect(errorOverlay).not.toBeVisible();
});
});
161 changes: 161 additions & 0 deletions e2e/home.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import { test, expect } from "@playwright/test";

test.describe("Home Page", () => {
test.beforeEach(async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 720 });
});

test("page loads and story grid renders", async ({ page }) => {
await page.goto("/");
await expect(page).toHaveTitle(/PlotLink/i);
// Story grid should render with story cards (skip if no data in CI)
const grid = page.locator(".grid");
if (!(await grid.first().isVisible({ timeout: 15000 }).catch(() => false))) {
test.skip();
return;
}
// Grid should contain story card links
const storyLinks = page.locator("a[href^='/story/']");
expect(await storyLinks.count()).toBeGreaterThan(0);
});

test("FilterBar is visible and dropdowns work", async ({ page }) => {
await page.goto("/");

const writerButton = page.locator("button").filter({ hasText: /writer:/ }).first();
// FilterBar may not render if page structure differs — skip gracefully
if (!(await writerButton.isVisible({ timeout: 10000 }).catch(() => false))) {
test.skip();
return;
}

await writerButton.click();

const dropdown = page.locator("[class*='absolute']").filter({ hasText: "Human" });
await expect(dropdown.first()).toBeVisible();

// Close
await page.locator("h1, h2, header").first().click();
});

test("sort dropdown shows Recent and Trending options", async ({ page }) => {
await page.goto("/");

const sortButton = page.locator("button").filter({ hasText: /sort:/ }).first();
if (!(await sortButton.isVisible({ timeout: 10000 }).catch(() => false))) {
test.skip();
return;
}
await sortButton.click();

const recentOption = page.locator("[class*='absolute'] button").filter({ hasText: "Recent" });
const trendingOption = page.locator("[class*='absolute'] button").filter({ hasText: "Trending" });
await expect(recentOption.first()).toBeVisible({ timeout: 3000 });
await expect(trendingOption.first()).toBeVisible();
});

test("tab switch (Trending) loads different results", async ({ page }) => {
await page.goto("/");

// Skip if no data available
if (!(await page.locator(".grid").first().isVisible({ timeout: 15000 }).catch(() => false))) {
test.skip();
return;
}
const initialLinks = await page.locator("a[href^='/story/']").count();

const sortButton = page.locator("button").filter({ hasText: /sort:/ }).first();
if (!(await sortButton.isVisible({ timeout: 10000 }).catch(() => false))) {
test.skip();
return;
}
await sortButton.click();
const trendingOption = page.locator("[class*='absolute'] button").filter({ hasText: "Trending" });
await trendingOption.first().click();

// Page should render with trending content
if (!(await page.locator(".grid").first().isVisible({ timeout: 15000 }).catch(() => false))) {
test.skip();
return;
}
const trendingLinks = await page.locator("a[href^='/story/']").count();
// Both views should have content
expect(initialLinks).toBeGreaterThan(0);
expect(trendingLinks).toBeGreaterThan(0);
});

test("genre filter updates URL", async ({ page }) => {
await page.goto("/");

const genreButton = page.locator("button").filter({ hasText: /genre:/ }).first();
if (!(await genreButton.isVisible({ timeout: 10000 }).catch(() => false))) {
test.skip();
return;
}
await genreButton.click();

const allGenres = page.locator("[class*='absolute'] button").filter({ hasText: "All genres" });
await expect(allGenres.first()).toBeVisible({ timeout: 3000 });

const genreOptions = page.locator("[class*='absolute'] button");
const count = await genreOptions.count();
if (count > 1) {
await genreOptions.nth(1).click();
await expect(page).toHaveURL(/genre=/);
}
});

test("language filter selects option and updates URL", async ({ page }) => {
await page.goto("/");

const langButton = page.locator("button").filter({ hasText: /lang:/ }).first();
if (!(await langButton.isVisible({ timeout: 10000 }).catch(() => false))) {
test.skip();
return;
}
await langButton.click();

const allLangs = page.locator("[class*='absolute'] button").filter({ hasText: "All languages" });
await expect(allLangs.first()).toBeVisible({ timeout: 3000 });

const langOptions = page.locator("[class*='absolute'] button");
const count = await langOptions.count();
if (count > 1) {
await langOptions.nth(1).click();
await expect(page).toHaveURL(/lang=/);
}
});

test("pagination navigates between pages", async ({ page }) => {
await page.goto("/");
if (!(await page.locator(".grid").first().isVisible({ timeout: 15000 }).catch(() => false))) {
test.skip();
return;
}

// Check for pagination controls
const nextLink = page.locator("a").filter({ hasText: "Next" });
if (await nextLink.count() > 0) {
// Verify Next link points to page 2
const href = await nextLink.first().getAttribute("href");
expect(href).toContain("page=2");

// Click Next and verify page changes
await nextLink.first().click();
await expect(page).toHaveURL(/page=2/);

// Page 2 should still have content or show empty state
await page.locator("body").waitFor();

// Previous link should now exist
const prevLink = page.locator("a").filter({ hasText: "Previous" });
if (await prevLink.count() > 0) {
expect(await prevLink.first().getAttribute("href")).not.toContain("page=2");
}
} else {
// Dataset has fewer than 24 items — pagination not shown (valid)
const storyCount = await page.locator("a[href^='/story/']").count();
expect(storyCount).toBeLessThanOrEqual(24);
}
});
});
57 changes: 57 additions & 0 deletions e2e/navigation.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { test, expect } from "@playwright/test";

test.describe("Navigation", () => {
test("NavBar logo links to home", async ({ page }) => {
await page.goto("/create");
const logo = page.getByText("PlotLink").first();
await expect(logo).toBeVisible({ timeout: 10000 });
await logo.click();
await expect(page).toHaveURL("/");
});

test("NavBar Create link navigates to /create", async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 720 });
await page.goto("/");
const createLink = page.locator("a[href='/create']").first();
await expect(createLink).toBeVisible({ timeout: 10000 });
await createLink.click();
await expect(page).toHaveURL("/create");
});

test("Footer renders with PlotLink branding", async ({ page }) => {
await page.goto("/");
// Footer contains "PlotLink" copyright text
const footer = page.locator("footer");
await expect(footer).toBeVisible({ timeout: 10000 });
// Verify footer has expected content
await expect(footer.getByText(/PlotLink/)).toBeVisible();
});

test("no console errors on navigation", async ({ page }) => {
const errors: string[] = [];
page.on("console", (msg) => {
if (msg.type() === "error") {
errors.push(msg.text());
}
});

await page.goto("/");
// Skip if no data renders (Supabase may be unreachable in CI)
await page.locator(".grid").first().waitFor({ timeout: 15000 }).catch(() => {});

// Navigate to create
await page.goto("/create");
await page.locator("body").waitFor();

const realErrors = errors.filter(
(e) =>
!e.includes("Failed to fetch") &&
!e.includes("net::ERR") &&
!e.includes("Hydration") &&
!e.includes("RPC") &&
!e.includes("favicon"),
);

expect(realErrors).toEqual([]);
});
});
Loading
Loading