diff --git a/README.md b/README.md index 075ba54..93a3fbc 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,10 @@ just test-all # pre-review local validation just serve # Dioxus dev server ``` +`just serve` starts against the current projection database without seeding demo +content. For browser e2e or a local demo feed, opt in explicitly: +`POLYMODEL_SAMPLE_DATA=auto just serve`. + ## Stack - Rust 2024 / nightly diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts index 4c44c03..4a50042 100644 --- a/e2e/playwright.config.ts +++ b/e2e/playwright.config.ts @@ -1,20 +1,52 @@ -import { defineConfig, devices } from '@playwright/test'; -import { existsSync, readdirSync, realpathSync, statSync } from 'node:fs'; -function findNixChromiumHeadlessShell(): string | undefined { const browsersPath = process.env.PLAYWRIGHT_BROWSERS_PATH; if (!browsersPath || !existsSync(browsersPath)) return undefined; const findExecutable = (root: string): string | undefined => { for (const entry of readdirSync(root)) { const candidate = `${root}/${entry}`; const stat = statSync(candidate); if (stat.isFile() && entry === 'chrome-headless-shell') return candidate; if (stat.isDirectory()) { const found = findExecutable(candidate); if (found) return found; } } return undefined; }; for (const entry of readdirSync(browsersPath)) { if (entry.startsWith('chromium_headless_shell-')) { const found = findExecutable(realpathSync(`${browsersPath}/${entry}`)); if (found) return found; } } return undefined; } const chromiumExecutablePath = findNixChromiumHeadlessShell(); export default defineConfig({ - testDir: './tests', +import { defineConfig, devices } from "@playwright/test"; +import { existsSync, readdirSync, realpathSync, statSync } from "node:fs"; +function findNixChromiumHeadlessShell(): string | undefined { + const browsersPath = process.env.PLAYWRIGHT_BROWSERS_PATH; + if (!browsersPath || !existsSync(browsersPath)) return undefined; + const findExecutable = (root: string): string | undefined => { + for (const entry of readdirSync(root)) { + const candidate = `${root}/${entry}`; + const stat = statSync(candidate); + if (stat.isFile() && entry === "chrome-headless-shell") return candidate; + if (stat.isDirectory()) { + const found = findExecutable(candidate); + if (found) return found; + } + } + return undefined; + }; + for (const entry of readdirSync(browsersPath)) { + if (entry.startsWith("chromium_headless_shell-")) { + const found = findExecutable(realpathSync(`${browsersPath}/${entry}`)); + if (found) return found; + } + } + return undefined; +} +const chromiumExecutablePath = findNixChromiumHeadlessShell(); +export default defineConfig({ + testDir: "./tests", timeout: 30_000, expect: { timeout: 5_000 }, use: { - baseURL: 'http://127.0.0.1:8080', - trace: 'on-first-retry', + baseURL: "http://127.0.0.1:60000", // avoid ports likely to be in use + trace: "on-first-retry", }, projects: [ - { name: 'chromium', use: { ...devices['Desktop Chrome'], launchOptions: chromiumExecutablePath ? { executablePath: chromiumExecutablePath } : undefined } }, + { + name: "chromium", + use: { + ...devices["Desktop Chrome"], + launchOptions: chromiumExecutablePath ? { executablePath: chromiumExecutablePath } : undefined, + }, + }, ], webServer: { - command: 'just serve', - url: 'http://127.0.0.1:8080', - reuseExistingServer: true, - timeout: 120_000, + command: + "rm -f data/polymodel-e2e.db && DATABASE_URL=sqlite:./data/polymodel-e2e.db POLYMODEL_SAMPLE_DATA=auto just serve --port 60000", + cwd: "..", + url: "http://127.0.0.1:60000", + reuseExistingServer: false, + timeout: 600_000, }, }); diff --git a/e2e/tests/home.spec.ts b/e2e/tests/home.spec.ts index a3a2299..18ce0ed 100644 --- a/e2e/tests/home.spec.ts +++ b/e2e/tests/home.spec.ts @@ -7,20 +7,19 @@ test('home page shows the product shell and discovery feed with reusable thing c await expect(page.getByRole('link', { name: 'Polymodel home' })).toBeVisible(); await expect(page.getByRole('searchbox', { name: 'Search Polymodel' })).toBeVisible(); await expect(page.getByRole('link', { name: 'Publish' })).toBeVisible(); - await expect(page.getByRole('button', { name: 'Sign in' })).toBeVisible(); + await expect(page.getByRole('banner').getByRole('button', { name: 'Sign in' })).toBeVisible(); - // Discovery home heading + feed selector (Hot default, Following disabled). - await expect(page.getByRole('heading', { name: 'Browse the catalog' })).toBeVisible(); + // Discovery feed selector (Hot default; Following now resolves to a signed-out empty state). await expect(page.getByRole('tab', { name: 'Hot' })).toHaveAttribute('aria-selected', 'true'); await expect(page.getByRole('tab', { name: 'Recent' })).toHaveAttribute('aria-selected', 'false'); - await expect(page.getByRole('tab', { name: 'Following' })).toBeDisabled(); + await expect(page.getByRole('tab', { name: 'Following' })).toHaveAttribute('aria-selected', 'false'); // No inline catalog search box on the home surface anymore. await expect(page.getByRole('searchbox', { name: 'Search model projects' })).toHaveCount(0); const results = page.getByLabel('Browse results'); await expect(results).toBeVisible(); - await expect(results.getByRole('article')).toHaveCount(3); + expect(await results.getByRole('article').count()).toBeGreaterThanOrEqual(3); await expect(page.getByRole('heading', { name: 'Parametric enclosure kit' })).toBeVisible(); await expect(page.getByText('by Ari Chen')).toBeVisible(); await expect(page.getByText('#parametric')).toBeVisible(); @@ -32,30 +31,28 @@ test('home page shows the product shell and discovery feed with reusable thing c await expect(page.getByText('by mira.tools')).toBeVisible(); await expect(page.getByRole('heading', { name: 'Modular calibration tower' })).toBeVisible(); - await expect(page.getByRole('img', { name: 'Blueprint placeholder for missing preview media' })).toHaveCount(2); - await expect(page.getByText('No preview')).toHaveCount(2); - await expect(page.getByText('preview pending')).toHaveCount(2); + expect(await page.getByRole('img', { name: 'Blueprint placeholder for missing preview media' }).count()).toBeGreaterThanOrEqual(2); await expect(page.getByLabel('Browse results placeholder')).toHaveCount(0); await expect(page.getByText('Empty')).toHaveCount(0); }); -test('global shell search navigates client-side to /search', async ({ page }) => { +test('global shell search navigates to /search', async ({ page }) => { await page.goto('/'); - // Gate on a rendered feed card so the wasm client has hydrated and wired the - // search field's keydown handler before we type — otherwise Enter races - // hydration and is dropped (the SSR input accepts fill but no handler runs). + // Gate on a rendered feed card so the shell has loaded before we use search. + // Search is a native GET form, so this works before and after hydration. await expect(page.getByLabel('Browse results').getByRole('article').first()).toBeVisible(); const search = page.getByRole('searchbox', { name: 'Search Polymodel' }); await search.fill('enclosure'); - await search.press('Enter'); + await page.getByRole('button', { name: 'Search' }).click(); await expect(page).toHaveURL(/\/search\?q=enclosure$/); - await expect(page.getByRole('heading', { name: 'Search', exact: true })).toBeVisible(); - await expect(page.getByText('Not wired up yet')).toBeVisible(); - await expect(page.getByText(/Searched for/)).toBeVisible(); + await expect(page.getByRole('region', { name: 'Search', exact: true })).toBeVisible(); + await expect(page.getByText('"enclosure"')).toBeVisible(); + await expect(page.getByLabel('Search results')).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Parametric enclosure kit' })).toBeVisible(); }); test('shell header is present on a deep route', async ({ page }) => { @@ -68,8 +65,9 @@ test('shell header is present on a deep route', async ({ page }) => { test('placeholder routes resolve with honest, non-faked content', async ({ page }) => { await page.goto('/search'); - await expect(page.getByRole('heading', { name: 'Search', exact: true })).toBeVisible(); - await expect(page.getByText('Not wired up yet')).toBeVisible(); + await expect(page.getByRole('region', { name: 'Search', exact: true })).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Search from the header' })).toBeVisible(); + await expect(page.getByText('Find indexed projects by name, tag, author, or description.')).toBeVisible(); }); test('home thing cards remain readable at narrow widths', async ({ page }) => { @@ -80,7 +78,7 @@ test('home thing cards remain readable at narrow widths', async ({ page }) => { await expect(page.getByRole('heading', { name: 'Parametric enclosure kit' })).toBeVisible(); await expect(page.getByRole('heading', { name: 'Untitled thing' })).toBeVisible(); await expect(page.getByRole('heading', { name: 'Modular calibration tower' })).toBeVisible(); - await expect(page.getByRole('img', { name: 'Blueprint placeholder for missing preview media' })).toHaveCount(2); + expect(await page.getByRole('img', { name: 'Blueprint placeholder for missing preview media' }).count()).toBeGreaterThanOrEqual(2); }); test('foundation route shows foundation primitives and reusable states', async ({ page }) => { diff --git a/e2e/tests/publish.spec.ts b/e2e/tests/publish.spec.ts index 92332df..909e5f4 100644 --- a/e2e/tests/publish.spec.ts +++ b/e2e/tests/publish.spec.ts @@ -11,7 +11,7 @@ test('signed-out /publish shows the sign-in callout, not the wizard', async ({ p await page.goto('/publish'); await expect(page.getByRole('heading', { name: 'Share your models' })).toBeVisible(); - const callout = page.locator('section[aria-label="Sign in to publish"]'); + const callout = page.locator('section[aria-label="Sign in"]'); await expect(callout.getByRole('button', { name: 'Sign in' })).toBeVisible(); // The signed-out surface must not expose the wizard stepper or publish action. @@ -22,6 +22,6 @@ test('signed-out /publish shows the sign-in callout, not the wizard', async ({ p test('signed-out publish callout returns the user to /publish after sign-in', async ({ page }) => { await page.goto('/publish'); - const returnTo = page.locator('section[aria-label="Sign in to publish"] input[name="return_to"]'); + const returnTo = page.locator('section[aria-label="Sign in"] input[name="return_to"]'); await expect(returnTo).toHaveValue('/publish'); }); diff --git a/justfile b/justfile index cc10860..779d8a3 100644 --- a/justfile +++ b/justfile @@ -51,8 +51,8 @@ test-all: fix check lint test test-server e2e: cd e2e && npm test # Start the Dioxus dev server. -serve: - dx serve +serve *ARGS: + dx serve {{ ARGS }} # Build the Dioxus app for web. build-web: dx build --platform web diff --git a/src/indexing/config.rs b/src/indexing/config.rs index 0a14052..cf1c168 100644 --- a/src/indexing/config.rs +++ b/src/indexing/config.rs @@ -24,8 +24,9 @@ pub struct ServerConfig { /// defaults the OAuth client origin to `https://polymodel.space`, a functional /// hosted client; set `POLYMODEL_BASE_URL` for local or alternate origins. pub base_url: Option, - /// Local/demo sample-data mode. `auto` seeds conservative local SQLite DBs - /// only when empty; `force` seeds any SQLite DB when empty; `off` disables it. + /// Local/demo sample-data mode. Defaults to `off`; `auto` seeds + /// conservative local SQLite DBs only when empty; `force` seeds any SQLite + /// DB when empty. pub sample_data: SampleDataMode, } @@ -46,7 +47,7 @@ impl SampleDataMode { tracing::warn!(%value, "unknown POLYMODEL_SAMPLE_DATA value; falling back to off"); Self::Off } - None => Self::Auto, + None => Self::Off, } } @@ -113,4 +114,9 @@ mod tests { SampleDataMode::Auto ); } + + #[test] + fn sample_data_defaults_to_off() { + assert_eq!(SampleDataMode::from_env(None), SampleDataMode::Off); + } } diff --git a/src/indexing/sample_data.rs b/src/indexing/sample_data.rs index 28045d2..b307448 100644 --- a/src/indexing/sample_data.rs +++ b/src/indexing/sample_data.rs @@ -1,9 +1,9 @@ //! Deterministic local/demo sample data for appview read paths. //! -//! This module writes projection-shaped SQLite rows only when the local/demo -//! database has no Polymodel things. It lets ordinary local `just serve` and e2e -//! exercise the same `getFeed`, `getThing`, and `getModel` appview contracts as -//! production reads without UI-only fixtures. +//! This module writes projection-shaped SQLite rows only when explicitly enabled +//! and the local/demo database has no Polymodel things. It lets e2e and local +//! demo runs exercise the same `getFeed`, `getThing`, and `getModel` appview +//! contracts as production reads without UI-only fixtures. use jacquard_common::types::blob::BlobRef; use polymodel_api::space_polymodel::{ diff --git a/src/shell.rs b/src/shell.rs index 583222e..96a410e 100644 --- a/src/shell.rs +++ b/src/shell.rs @@ -55,9 +55,9 @@ pub(crate) fn AppShell() -> Element { } } -/// Global directed-search field. Submits client-side through the navigator to -/// the `/search` route; it never performs a full document navigation (contrast -/// `SessionControl`, which issues a real GET to `/oauth/start`). +/// Global directed-search field. Uses a plain GET form so SSR/pre-hydration +/// submissions still resolve to `/search?q=...`; Dioxus keeps the value synced +/// with the current route once hydrated. #[component] fn ShellSearch() -> Element { let route = use_route::(); @@ -77,7 +77,7 @@ fn ShellSearch() -> Element { } rsx! { - div { class: "app-search", role: "search", + form { class: "app-search", role: "search", action: "/search", method: "get", input { class: "app-search-input", r#type: "search", @@ -86,21 +86,10 @@ fn ShellSearch() -> Element { aria_label: "Search Polymodel", value: "{query}", oninput: move |event| query.set(event.value()), - onkeydown: move |event| { - if event.key() == Key::Enter { - event.prevent_default(); - let q = query.read().trim().to_string(); - navigator().push(Route::Search { q }); - } - }, } button { class: "button button-secondary app-search-button", - r#type: "button", - onclick: move |_| { - let q = query.read().trim().to_string(); - navigator().push(Route::Search { q }); - }, + r#type: "submit", "Search" } }