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
67 changes: 67 additions & 0 deletions .github/workflows/e2e-remote.yml
Original file line number Diff line number Diff line change
@@ -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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
35 changes: 34 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/run-executable-local.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ Open **Settings** and set the following under **Execution Mode**:
| -------------------- | -------------------------------------------------------------------------- |
| Location | `local` |
| Backend kind | `executable` |
| Working directory | `<repo>/local_runs/step-70/build` (or any writable directory) |
| Working directory | `<repo>/local_runs/step-70/` (or any writable directory) |
| Executable path | `<repo>/local_runs/step-70/build/step-70` |
| Parameters file name | `parameters.json` or `parameters.prm` (generated on first probe if absent) |

Expand Down
51 changes: 51 additions & 0 deletions e2e-remote/auth.spec.ts
Original file line number Diff line number Diff line change
@@ -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 }
)
})
})
145 changes: 145 additions & 0 deletions e2e-remote/fixtures.ts
Original file line number Diff line number Diff line change
@@ -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<TestFixtures, WorkerFixtures>({
/**
* 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'
57 changes: 57 additions & 0 deletions e2e-remote/global-setup.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<void> {
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}`
)
}
}
Loading
Loading