diff --git a/.github/workflows/e2e-remote.yml b/.github/workflows/e2e-remote.yml new file mode 100644 index 0000000..70b0345 --- /dev/null +++ b/.github/workflows/e2e-remote.yml @@ -0,0 +1,67 @@ +name: E2E Remote + +on: + push: + branches: + - main + pull_request: + branches: + - main + workflow_dispatch: + +jobs: + e2e-tier2: + runs-on: ubuntu-latest + env: + # docker compose parses the full docker-compose.yml, including the + # coral-ssh-slurm volume (not started here) that requires this variable. + # /dev/null satisfies the :? constraint. + SSH_PUB_KEY_PATH: /dev/null + + steps: + - name: Load coral-remote-server deploy key + uses: webfactory/ssh-agent@v0.9.0 + with: + ssh-private-key: ${{ secrets.CORAL_REMOTE_SERVER_DEPLOY_KEY }} + + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Init coral-remote-server submodule + run: git submodule update --init coral-remote-server + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Install npm dependencies + run: npm ci + + - name: Build coral-remote-server Docker image + run: docker compose build coral-remote-server + + - name: Start coral-remote-server + run: docker compose up -d coral-remote-server + + - name: Build app for E2E + run: npm run build + + - name: Run Tier 2 E2E tests + # xvfb-run provides an in-memory X Virtual Framebuffer so Electron can + # open BrowserWindows without a real display — effectively headless CI. + run: xvfb-run --auto-servernum npm run test:e2e:remote + env: + ELECTRON_DISABLE_SANDBOX: "1" + + - name: Upload E2E failure artifacts + if: failure() + uses: actions/upload-artifact@v4 + with: + name: e2e-remote-test-results + path: test-results/ + retention-days: 7 + + - name: Stop containers + if: always() + run: docker compose down diff --git a/CHANGELOG.md b/CHANGELOG.md index e0e2410..ee7efaf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,9 +28,14 @@ See [docs/changelog-template.md](docs/changelog-template.md) for formatting your - `coral-remote-server` is now included in the main `docker-compose.yml` alongside `coral-ssh-slurm` and `coral-visualizer`, so a single `docker compose up` starts the full stack. The database is persisted in `coral-remote-server/data/coral.db` via a directory volume mount. +### Settings + +- [#201](https://github.com/2listic/dealiiX-platform/pull/201) Default settings now pre-fill `urlRemoteServer` (`http://localhost:8080`) and `urlVisualizer` (`http://localhost:8008`) for first-time users, matching the standard local Docker setup. The spurious "settings invalid or outdated" error toast on first launch (caused by treating an absent key the same as a corrupt value) has been removed. + ### Testing - [#193](https://github.com/2listic/dealiiX-platform/issues/193) Tier 1 E2E test suite added using Playwright and Electron. Tests cover app launch, sidebar node loading, drag-and-drop node creation, undo/redo, JSON graph import, edge connection between handles, and subnetwork collapse/explode. Tests run sequentially with one worker so a single Electron instance is shared across all tests, matching CI behaviour. A fixed 1280×800 window is enforced via the `E2E_TEST` env var for reproducible bounding-box calculations. CI runs the suite headlessly via `xvfb-run` and uploads traces and screenshots as artifacts on failure. +- [#201](https://github.com/2listic/dealiiX-platform/pull/201) Tier 2 E2E test suite added covering auth and project-management flows that require `coral-remote-server`. Tests cover login, logout, project creation, rename, deletion, and round-trip graph save and load. A separate `playwright.remote.config.ts` config and `e2e-remote.yml` workflow run Tier 2 tests on every push to main and on manual dispatch, keeping the fast Docker-free `ci.yml` unchanged. Both tiers run against an isolated temporary electron-store so the developer's real settings and registered nodes are never touched. ### Project-Structure diff --git a/README.md b/README.md index 55b16af..b42a5db 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,38 @@ The suite runs with **one worker** (`workers: 1` in `playwright.config.ts`), mat On failure, Playwright saves **context** and **trace** to `test-results/`. To retrieve failure artifacts from a CI run, go to the **Actions** tab on GitHub → select the run → scroll to the **Artifacts** section at the bottom. +### E2E tests — Tier 2 (Playwright + Electron + coral-remote-server) + +Tier 2 covers auth and project-management flows that require `coral-remote-server` to be running. Tests live in `e2e-remote/` and use a separate Playwright config (`playwright.remote.config.ts`), so Tier 1 in `e2e/` is completely unaffected. + +**Prerequisites:** Docker must be installed and the `coral-remote-server` image must be built and running. + +```bash +# Build the image and start the container (first run builds; subsequent runs are fast) +docker compose up -d coral-remote-server +``` + +Once the server is up: + +```bash +# Build + run in one shot (recommended locally) +npm run test:e2e:remote:build + +# Run only (after a manual build, or when the server is already running) +npm run test:e2e:remote + +# Run a single spec file +npx playwright test --config playwright.remote.config.ts e2e-remote/auth.spec.ts +``` + +The global setup (`e2e-remote/global-setup.ts`) polls until the server is ready (up to 30 s) and then registers a shared test user (`e2etest`). The user is created idempotently, so re-running against the same container is safe. + +### Store isolation in E2E tests + +Both tiers launch Electron with `ELECTRON_USERDATA` pointing at a fresh temp directory (see `e2e/fixtures.ts` / `e2e-remote/fixtures.ts`), and [electron/main.ts](electron/main.ts) calls `app.setPath('userData', …)` with it before anything else runs. This keeps the developer's real settings, registered nodes, and auth token untouched by test runs. + +For the redirect to actually take effect, `ipcHandlers` (which pulls in the `electron-store` singleton via `storage.ts`) must **not** be statically imported in `main.ts`: ES module static imports are fully evaluated before the importing module's own top-level code runs, which would construct the store — and resolve its file path — before `app.setPath` runs. `ipcHandlers` is therefore imported dynamically inside `app.whenReady()`. + ## Debugging ### Debugging Electron @@ -162,7 +194,8 @@ Only works on macOS systems The GitHub Actions workflows are defined in the [.github/workflows](.github/workflows) directory: -- **[ci.yml](.github/workflows/ci.yml)**: runs on every push and pull request to `main` — Svelte type check (`npm run check`), Electron type check (`npm run check:electron`), unit tests (`npm run test`), and renderer-only E2E tests (`npm run test:e2e`). +- **[ci.yml](.github/workflows/ci.yml)**: runs on every push and pull request to `main` — Svelte type check (`npm run check`), Electron type check (`npm run check:electron`), unit tests (`npm run test`), and Tier 1 E2E tests (`npm run test:e2e`). No Docker required. +- **[e2e-remote.yml](.github/workflows/e2e-remote.yml)**: runs on every push and pull request to `main` — checks out the `coral-remote-server` submodule using a read-only SSH deploy key (stored as the `CORAL_REMOTE_SERVER_DEPLOY_KEY` repository secret), builds its Docker image, starts the container, and runs Tier 2 E2E tests (`npm run test:e2e:remote`) covering auth and project-management flows. - **[release-linux.yml](.github/workflows/release-linux.yml)** / **[release-macos.yml](.github/workflows/release-macos.yml)**: triggered on version tags (`v*`) or manually — runs the full check/test/build pipeline and uploads artifacts to the GitHub Release. ### Creating a Release diff --git a/docs/run-executable-local.md b/docs/run-executable-local.md index 0d62436..5bd2cac 100644 --- a/docs/run-executable-local.md +++ b/docs/run-executable-local.md @@ -55,7 +55,7 @@ Open **Settings** and set the following under **Execution Mode**: | -------------------- | -------------------------------------------------------------------------- | | Location | `local` | | Backend kind | `executable` | -| Working directory | `/local_runs/step-70/build` (or any writable directory) | +| Working directory | `/local_runs/step-70/` (or any writable directory) | | Executable path | `/local_runs/step-70/build/step-70` | | Parameters file name | `parameters.json` or `parameters.prm` (generated on first probe if absent) | diff --git a/e2e-remote/auth.spec.ts b/e2e-remote/auth.spec.ts new file mode 100644 index 0000000..c969e8c --- /dev/null +++ b/e2e-remote/auth.spec.ts @@ -0,0 +1,51 @@ +import { test, expect, TEST_USER } from './fixtures' + +test.describe('Auth', () => { + test('login button is visible and shows "Login" when logged out', async ({ + unauthedPage: page, + }) => { + await expect(page.locator('[data-testid="login-status"]')).toHaveText( + 'Login' + ) + // The label is the visible clickable element; verify it lacks the disabled class. + await expect(page.locator('label[for="login-button"]')).not.toHaveClass( + /disabled/ + ) + }) + + test('user can log in with valid credentials', async ({ + unauthedPage: page, + }) => { + await page.locator('label[for="login-button"]').click() + + await expect(page.locator('#login-username')).toBeVisible() + await page.locator('#login-username').fill(TEST_USER.username) + await page.locator('#login-password').fill(TEST_USER.password) + await page + .locator('[data-testid="login-form"] button[type="submit"]') + .click() + + await expect(page.locator('[data-testid="login-status"]')).toHaveText( + TEST_USER.username, + { timeout: 10_000 } + ) + }) + + test('user can log out', async ({ authedPage: page }) => { + await expect(page.locator('[data-testid="login-status"]')).toHaveText( + TEST_USER.username + ) + + await page.locator('label[for="login-button"]').click() + await expect(page.locator('.confirmation-modal')).toBeVisible() + await page + .locator('.confirmation-modal') + .getByRole('button', { name: 'Logout' }) + .click() + + await expect(page.locator('[data-testid="login-status"]')).toHaveText( + 'Login', + { timeout: 10_000 } + ) + }) +}) diff --git a/e2e-remote/fixtures.ts b/e2e-remote/fixtures.ts new file mode 100644 index 0000000..e9ba3b6 --- /dev/null +++ b/e2e-remote/fixtures.ts @@ -0,0 +1,145 @@ +import { test as base, expect, _electron as electron } from '@playwright/test' +import type { ElectronApplication, Page } from '@playwright/test' +import { mkdtempSync, rmSync } from 'fs' +import { join } from 'path' +import { tmpdir } from 'os' +import { clearCanvas, waitForToasts } from './helpers' + +export const REMOTE_URL = 'http://localhost:8080' + +export const TEST_USER = { + username: 'e2etest', + password: 'e2epassword', +} + +type WorkerFixtures = { + electronApp: ElectronApplication +} + +type TestFixtures = { + /** Page fixture with a valid auth token seeded into the store. */ + authedPage: Page + /** Page fixture with no auth token — starts logged out. */ + unauthedPage: Page +} + +export const test = base.extend({ + /** + * Worker-scoped fixture: launches the Electron application once, shared + * across all tests in the worker. + * + * Uses an isolated userData directory so the real electron-store is never + * read or written: + * - registered_nodes falls back to defaultNodes.json (built-in fallback) + * - settings falls back to createDefaultSettings(), which already sets + * backendKind: 'coral' and urlRemoteServer: 'http://localhost:8080' + */ + electronApp: [ + async ({}, use) => { + const tempUserData = mkdtempSync(join(tmpdir(), 'dealiix-e2e-remote-')) + + const app = await electron.launch({ + args: ['.'], + env: { ...process.env, E2E_TEST: '1', ELECTRON_USERDATA: tempUserData }, + }) + + await app.firstWindow().then((page) => + page.waitForSelector('[data-testid="flow-canvas"]', { + timeout: 60_000, + }) + ) + + await use(app) + + await app.close() + rmSync(tempUserData, { recursive: true, force: true }) + }, + { scope: 'worker' }, + ], + + /** + * Test-scoped fixture: each test starts fully authenticated with a clean canvas. + * + * Logs in fresh for every test so no auth state has to be shared between fixtures. + * Seeds the token into electron-store and reloads so loadAuth() picks it up, + * then clears the canvas and waits for any lingering toasts. + * Teardown is limited to clear electron-store because server has a pure stateless + * JWT token with a 24h expiration limit and no session state or token blacklist. + */ + authedPage: async ({ electronApp }, use) => { + const loginRes = await fetch(`${REMOTE_URL}/api/users/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(TEST_USER), + }) + if (!loginRes.ok) { + throw new Error( + `authedPage fixture: login failed with HTTP ${loginRes.status}. ` + + `Is coral-remote-server running at ${REMOTE_URL}?` + ) + } + const { token } = (await loginRes.json()) as { token: string } + + const page = await electronApp.firstWindow() + + await page.evaluate( + async (args) => { + const store = (window as any).electron.store + await store.set('access_token', args.token) + await store.set('username', args.username) + }, + { token, username: TEST_USER.username } + ) + await page.reload() + await page.waitForSelector('[data-testid="flow-canvas"]', { + timeout: 30_000, + }) + // Wait for auth state to propagate to the UI. + await expect(page.locator('[data-testid="login-status"]')).toHaveText( + TEST_USER.username, + { timeout: 5_000 } + ) + await clearCanvas(page) + await waitForToasts(page) + + await use(page) + + // Teardown: clear auth for the next test. + await page.evaluate(async () => { + const store = (window as any).electron.store + await store.remove('access_token') + await store.remove('username') + }) + }, + + /** + * Test-scoped fixture: each test starts logged out. + * + * Clears any stored auth token, reloads so the renderer starts with + * no auth state, then clears the canvas and waits for toasts. + */ + unauthedPage: async ({ electronApp }, use) => { + const page = await electronApp.firstWindow() + + await page.evaluate(async () => { + const store = (window as any).electron.store + await store.remove('access_token') + await store.remove('username') + }) + await page.reload() + await page.waitForSelector('[data-testid="flow-canvas"]', { + timeout: 30_000, + }) + // Confirm we are logged out before handing the page to the test. + await expect(page.locator('[data-testid="login-status"]')).toHaveText( + 'Login', + { timeout: 5_000 } + ) + await clearCanvas(page) + await waitForToasts(page) + + await use(page) + }, +}) + +export { expect } from '@playwright/test' diff --git a/e2e-remote/global-setup.ts b/e2e-remote/global-setup.ts new file mode 100644 index 0000000..f5c3b8e --- /dev/null +++ b/e2e-remote/global-setup.ts @@ -0,0 +1,57 @@ +const REMOTE_URL = 'http://localhost:8080' + +const TEST_USER = { + username: 'e2etest', + email: 'e2etest@test.com', + password: 'e2epassword', +} + +/** + * Playwright global setup for Tier 2 (remote) E2E tests. + * + * Polls until coral-remote-server responds (up to 30 s), then registers + * the shared test user. A 409 response means the user already exists — + * treated as success for idempotency across repeated runs. + */ +export default async function globalSetup() { + await waitForServer() + await registerTestUser() +} + +// ── Private helpers ── + +async function waitForServer(): Promise { + const deadline = Date.now() + 30_000 + while (Date.now() < deadline) { + try { + // Any HTTP response (even 401 / 400) means the server is up. + const res = await fetch(`${REMOTE_URL}/api/users/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username: 'probe', password: 'probe' }), + }) + if (res.status !== undefined) return + } catch { + // Connection refused — keep polling. + } + await new Promise((r) => setTimeout(r, 1000)) + } + throw new Error( + `coral-remote-server did not become ready within 30 s at ${REMOTE_URL}` + ) +} + +async function registerTestUser(): Promise { + const res = await fetch(`${REMOTE_URL}/api/users/register`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(TEST_USER), + }) + // 409 = user already exists from a previous run — that's fine. + if (!res.ok && res.status !== 409) { + const body = await res.text() + throw new Error( + `Failed to register test user: HTTP ${res.status} — ${body}` + ) + } +} diff --git a/e2e-remote/helpers.ts b/e2e-remote/helpers.ts new file mode 100644 index 0000000..8b25304 --- /dev/null +++ b/e2e-remote/helpers.ts @@ -0,0 +1,35 @@ +import { expect } from '@playwright/test' +import type { Page } from '@playwright/test' + +/** + * Selects all nodes on the canvas and deletes them, then waits for the canvas to be empty. + * + * @param page - The Playwright Page object for the Electron window under test. + */ +export async function clearCanvas(page: Page): Promise { + const pane = page.locator('.svelte-flow__pane') + const box = await pane.boundingBox() + if (box) { + await page.mouse.move(box.x + 2, box.y + 2) + await page.mouse.down() + await page.mouse.move(box.x + box.width - 2, box.y + box.height - 2, { + steps: 5, + }) + await page.mouse.up() + await page.keyboard.press('Backspace') + await expect(page.locator('.svelte-flow__node')) + .toHaveCount(0, { timeout: 5_000 }) + .catch(() => {}) + } +} + +/** + * Waits for all toast notifications to disappear from the page. + * + * @param page - The Playwright Page object for the Electron window under test. + */ +export async function waitForToasts(page: Page): Promise { + await expect(page.locator('[role="alert"]')).toHaveCount(0, { + timeout: 15_000, + }) +} diff --git a/e2e-remote/projects.spec.ts b/e2e-remote/projects.spec.ts new file mode 100644 index 0000000..4584bab --- /dev/null +++ b/e2e-remote/projects.spec.ts @@ -0,0 +1,153 @@ +import { test, expect } from './fixtures' +import type { Page } from '@playwright/test' + +test.describe('Projects', () => { + test('can create a project and see it in the list', async ({ + authedPage: page, + }) => { + const name = `Test Project ${Date.now()}` + await createProject(page, name) + + await openLoadProjects(page) + await expect(page.locator('.project-card', { hasText: name })).toBeVisible() + + await deleteProjectByName(page, name) + await page.keyboard.press('Escape') + }) + + test('can rename a project', async ({ authedPage: page }) => { + const name = `Rename Me ${Date.now()}` + const newName = `Renamed ${Date.now()}` + await createProject(page, name) + + await openLoadProjects(page) + await page + .locator('.project-card', { hasText: name }) + .getByRole('button', { name: 'Edit' }) + .click() + + await expect(page.locator('h2', { hasText: 'Edit Project' })).toBeVisible() + // Only the Edit modal's form is in the DOM when it is open. + const nameInput = page.locator('#project-name') + await nameInput.clear() + await nameInput.fill(newName) + await page.getByRole('button', { name: 'Update' }).click() + + await expect( + page.locator('[role="alert"]').filter({ hasText: 'saved' }) + ).toBeVisible() + await expect(page.locator('[role="alert"]')).toHaveCount(0, { + timeout: 15_000, + }) + + // onUpdate() refreshes the list in place — the projects modal stays open. + await expect( + page.locator('.project-card', { hasText: newName }) + ).toBeVisible() + + await deleteProjectByName(page, newName) + await page.keyboard.press('Escape') + }) + + test('can delete a project', async ({ authedPage: page }) => { + const name = `Delete Me ${Date.now()}` + await createProject(page, name) + + await openLoadProjects(page) + const card = page.locator('.project-card', { hasText: name }) + await expect(card).toBeVisible() + + await card.getByRole('button', { name: 'Delete' }).click() + await page + .locator('.confirmation-modal') + .getByRole('button', { name: 'Delete' }) + .click() + + // Card disappears reactively from the list. + await expect(card).not.toBeVisible({ timeout: 5_000 }) + await expect( + page.locator('[role="alert"]').filter({ hasText: 'deleted' }) + ).toBeVisible() + + await page.keyboard.press('Escape') + }) + + test('can load a project and get a success toast', async ({ + authedPage: page, + }) => { + const name = `Load Me ${Date.now()}` + await createProject(page, name) + + await openLoadProjects(page) + await page + .locator('.project-card', { hasText: name }) + .getByRole('button', { name: 'Load' }) + .click() + + await expect( + page.locator('[role="alert"]').filter({ hasText: /loaded successfully/i }) + ).toBeVisible({ timeout: 10_000 }) + + // Cleanup + await expect(page.locator('[role="alert"]')).toHaveCount(0, { + timeout: 15_000, + }) + await openLoadProjects(page) + await deleteProjectByName(page, name) + await page.keyboard.press('Escape') + }) +}) + +// ── Helpers ── + +async function openProjectMenu(page: Page): Promise { + await page.locator('button[title="Project"]').click() +} + +async function createProject(page: Page, name: string): Promise { + await openProjectMenu(page) + await page + .locator('[role="menu"]') + .locator('button', { hasText: 'Save Project' }) + .click() + + await expect(page.locator('h2', { hasText: 'Save Project' })).toBeVisible() + await page.locator('#project-name').fill(name) + await page.getByRole('button', { name: 'Save' }).click() + + await expect( + page.locator('[role="alert"]').filter({ hasText: 'saved successfully' }) + ).toBeVisible() + await expect(page.locator('[role="alert"]')).toHaveCount(0, { + timeout: 15_000, + }) +} + +async function openLoadProjects(page: Page): Promise { + await openProjectMenu(page) + await page + .locator('[role="menu"]') + .locator('button', { hasText: 'Load Projects' }) + .click() + // Wait until the modal header is visible. + await expect(page.locator('h2', { hasText: 'Projects' })).toBeVisible() + // Info toast appears when there are no projects — wait for it to clear. + await expect(page.locator('[role="alert"]')).toHaveCount(0, { + timeout: 15_000, + }) +} + +async function deleteProjectByName(page: Page, name: string): Promise { + const card = page.locator('.project-card', { hasText: name }) + await card.getByRole('button', { name: 'Delete' }).click() + await page + .locator('.confirmation-modal') + .getByRole('button', { name: 'Delete' }) + .click() + await expect( + page.locator('[role="alert"]').filter({ hasText: 'deleted' }) + ).toBeVisible() + await expect(page.locator('[role="alert"]')).toHaveCount(0, { + timeout: 15_000, + }) +} diff --git a/e2e/fixtures.ts b/e2e/fixtures.ts index cb900d4..407a6df 100644 --- a/e2e/fixtures.ts +++ b/e2e/fixtures.ts @@ -1,5 +1,9 @@ -import { test as base, expect, _electron as electron } from '@playwright/test' +import { test as base, _electron as electron } from '@playwright/test' import type { ElectronApplication, Page } from '@playwright/test' +import { mkdtempSync, rmSync } from 'fs' +import { join } from 'path' +import { tmpdir } from 'os' +import { clearCanvas, waitForToasts } from './helpers' type WorkerFixtures = { electronApp: ElectronApplication @@ -13,49 +17,50 @@ type TestFixtures = { * to hand it to the test, then tears it down after `use` resolves. */ export const test = base.extend({ - // This fixture launches the Electron application. - // It is a worker-scoped fixture meaning it is shared across all tests in the same - // worker process, so that it avoids several cold starts speeding up the test execution. + /** + * Worker-scoped fixture: shared across all tests in the worker. + * + * Launches the Electron app with an isolated userData directory so the real + * electron-store is never read or written. The app starts from a clean state: + * - registered_nodes falls back to defaultNodes.json (built-in fallback) + * - settings falls back to createDefaultSettings() — backendKind: 'coral', + * so FlowCanvas renders and [data-testid="flow-canvas"] is always present + * + * The cold-start wait (up to 60 s) is paid once per worker; subsequent tests + * share the same Electron instance. + */ electronApp: [ async ({}, use) => { + // Isolated userData dir. + const tempUserData = mkdtempSync(join(tmpdir(), 'dealiix-e2e-')) + const app = await electron.launch({ args: ['.'], // same way as `npm run dev` does: `electron .` - env: { ...process.env, E2E_TEST: '1' }, + env: { ...process.env, E2E_TEST: '1', ELECTRON_USERDATA: tempUserData }, }) + + await app.firstWindow().then((page) => + page.waitForSelector('[data-testid="flow-canvas"]', { + timeout: 60_000, // cold-start (~30 s in CI). + }) + ) + await use(app) + await app.close() + rmSync(tempUserData, { recursive: true, force: true }) }, { scope: 'worker' }, // Worker scope fixture definition. ], - // The first BrowserWindow opened by the Electron application. - // This is a test-scoped fixture, meaning it runs once per test. + /** + * Test-scoped fixture: each test gets a fresh page instance with a clean canvas. + */ page: async ({ electronApp }, use) => { - // Receive the shared electronApp and wait for the canvas to mount. + // Receive the shared electronApp. const page = await electronApp.firstWindow() - await page.waitForSelector('[data-testid="flow-canvas"]', { - timeout: 60_000, // 60 s covers the cold-start (~30 s in CI), only paid once. - }) - // Clear any nodes left by the previous test. - const pane = page.locator('.svelte-flow__pane') - const box = await pane.boundingBox() - if (box) { - await page.mouse.move(box.x + 2, box.y + 2) - await page.mouse.down() - await page.mouse.move(box.x + box.width - 2, box.y + box.height - 2, { - steps: 5, - }) - await page.mouse.up() - await page.keyboard.press('Backspace') - // Wait for the deletion to propagate through Svelte reactivity. - await expect(page.locator('.svelte-flow__node')) - .toHaveCount(0, { timeout: 5_000 }) - .catch(() => {}) - } - // Wait for any toasts from the previous test to fully fade out. - await expect(page.locator('[role="alert"]')).toHaveCount(0, { - timeout: 15_000, - }) + await clearCanvas(page) + await waitForToasts(page) await use(page) }, }) diff --git a/e2e/helpers.ts b/e2e/helpers.ts index 74f5521..f019607 100644 --- a/e2e/helpers.ts +++ b/e2e/helpers.ts @@ -1,5 +1,39 @@ +import { expect } from '@playwright/test' import type { Locator, Page } from '@playwright/test' +/** + * Selects all nodes on the canvas and deletes them, then waits for the canvas to be empty. + * + * @param page - The Playwright Page object for the Electron window under test. + */ +export async function clearCanvas(page: Page): Promise { + const pane = page.locator('.svelte-flow__pane') + const box = await pane.boundingBox() + if (box) { + await page.mouse.move(box.x + 2, box.y + 2) + await page.mouse.down() + await page.mouse.move(box.x + box.width - 2, box.y + box.height - 2, { + steps: 5, + }) + await page.mouse.up() + await page.keyboard.press('Backspace') + await expect(page.locator('.svelte-flow__node')) + .toHaveCount(0, { timeout: 5_000 }) + .catch(() => {}) + } +} + +/** + * Waits for all toast notifications to disappear from the page. + * + * @param page - The Playwright Page object for the Electron window under test. + */ +export async function waitForToasts(page: Page): Promise { + await expect(page.locator('[role="alert"]')).toHaveCount(0, { + timeout: 15_000, + }) +} + /** * Simulates an HTML5 drag-and-drop from a sidebar node onto the SvelteFlow canvas. * diff --git a/electron/main.ts b/electron/main.ts index af96e8d..b3a81d4 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -2,11 +2,16 @@ import { app, BrowserWindow, screen } from 'electron/main' import path from 'path' import { fileURLToPath } from 'url' -import { registerIpcHandlers } from './ipcHandlers.js' - const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) +// Must run before any dynamic import that reaches storage.ts / electron-store, +// because electron-store reads app.getPath('userData') at construction time. +// Static imports are hoisted in ESM, so ipcHandlers is imported dynamically below. +if (process.env.ELECTRON_USERDATA) { + app.setPath('userData', process.env.ELECTRON_USERDATA) +} + let mainWindow: BrowserWindow | null = null function createWindow() { @@ -35,7 +40,8 @@ function createWindow() { // mainWindow.webContents.openDevTools() // open devTools by default } -app.whenReady().then(() => { +app.whenReady().then(async () => { + const { registerIpcHandlers } = await import('./ipcHandlers.js') registerIpcHandlers() createWindow() diff --git a/package.json b/package.json index 43d7c9b..2e27445 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,8 @@ "test": "vitest run", "test:e2e": "playwright test", "test:e2e:build": "npm run build && npm run test:e2e", + "test:e2e:remote": "playwright test --config playwright.remote.config.ts", + "test:e2e:remote:build": "npm run build && npm run test:e2e:remote", "lint": "eslint .", "lint:fix": "eslint . --fix", "check": "svelte-check --tsconfig ./tsconfig.json", diff --git a/playwright.remote.config.ts b/playwright.remote.config.ts new file mode 100644 index 0000000..e770e50 --- /dev/null +++ b/playwright.remote.config.ts @@ -0,0 +1,22 @@ +import { defineConfig } from '@playwright/test' + +/** + * Playwright configuration for Tier 2 E2E tests (auth + project management). + * + * Requires coral-remote-server to be running on localhost:8080 before the + * suite starts. Run locally with: + * docker compose up -d coral-remote-server + * npm run test:e2e:remote:build + */ +export default defineConfig({ + testDir: './e2e-remote', + timeout: 90_000, + workers: 1, + retries: 1, + globalSetup: './e2e-remote/global-setup.ts', + use: { + video: 'on-first-retry', + screenshot: 'only-on-failure', + trace: 'retain-on-failure', + }, +}) diff --git a/src/lib/components/LoginForm.svelte b/src/lib/components/LoginForm.svelte index 86fa8b7..37442dc 100644 --- a/src/lib/components/LoginForm.svelte +++ b/src/lib/components/LoginForm.svelte @@ -34,6 +34,7 @@
{ event.preventDefault() diff --git a/src/lib/components/layout/SidebarButtons.svelte b/src/lib/components/layout/SidebarButtons.svelte index ede4aea..fe4d6fc 100644 --- a/src/lib/components/layout/SidebarButtons.svelte +++ b/src/lib/components/layout/SidebarButtons.svelte @@ -282,7 +282,7 @@ style="display: none" aria-label="Login" > - + {loginText}
diff --git a/src/lib/stores/settingsStore.svelte.ts b/src/lib/stores/settingsStore.svelte.ts index 41c50ed..4d01064 100644 --- a/src/lib/stores/settingsStore.svelte.ts +++ b/src/lib/stores/settingsStore.svelte.ts @@ -11,10 +11,14 @@ let settings = $state(createDefaultSettings()) // Load initial settings from electron-store. const loadSettings = async () => { if (window.electron?.store) { - const storedSettings = await window.electron.store.get('settings', {}) - if (isValidAppSettings(storedSettings)) { + const storedSettings = await window.electron.store.get('settings') + if (storedSettings === undefined) { + // Key absent — first launch or isolated E2E store. Silently use defaults. + settings = createDefaultSettings() + } else if (isValidAppSettings(storedSettings)) { settings = storedSettings } else { + // Key present but schema is wrong (e.g. after an app upgrade). settings = createDefaultSettings() toastState.add({ message: 'Saved settings were invalid or outdated — defaults restored.', diff --git a/src/lib/types/settingsTypes.ts b/src/lib/types/settingsTypes.ts index dc3639b..4071ba1 100644 --- a/src/lib/types/settingsTypes.ts +++ b/src/lib/types/settingsTypes.ts @@ -65,8 +65,8 @@ const defaultExecutionTargetSettings = (): ExecutionTargetSettings => ({ }) export const createDefaultSettings = (): AppSettings => ({ - urlVisualizer: '', - urlRemoteServer: '', + urlVisualizer: 'http://localhost:8008', + urlRemoteServer: 'http://localhost:8080', execution: { location: 'remote', backendKind: 'coral',