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
154 changes: 128 additions & 26 deletions .github/workflows/pr-verification.yml
Original file line number Diff line number Diff line change
@@ -1,18 +1,13 @@
name: PR Verification

on:
push:
branches: ['**']
pull_request:

jobs:
build:
# run on the matrix platform
runs-on: ${{ matrix.platform }}
strategy:
# do not fail other matrix runs if one fails
fail-fast: false
# set all platforms our test should run on
matrix:
platform: [windows-latest]
timeout-minutes: 30
Expand All @@ -21,20 +16,6 @@ jobs:
- name: Checkout
uses: actions/checkout@v6

- name: Tauri dependencies
if: matrix.platform == 'ubuntu-latest'
run: |
sudo apt-get update &&
sudo apt-get install -y \
libwebkit2gtk-4.1-dev \
libayatana-appindicator3-dev \
webkit2gtk-driver \
xvfb

- name: install WebDriver (Windows)
if: matrix.platform == 'windows-latest'
run: cargo install tauri-driver

- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
with:
Expand Down Expand Up @@ -72,12 +53,133 @@ jobs:
- name: Run Clippy
run: cargo clippy --manifest-path src-tauri/Cargo.toml --all-targets -- -D warnings

- name: Setup AppImage environment (Linux)
if: matrix.platform == 'ubuntu-latest'
run: |
sudo add-apt-repository universe
sudo apt-get update
sudo apt-get install -y libfuse2

- name: Build app (Tauri, no bundle)
run: pnpm run tauri build

system-tests:
runs-on: windows-latest
timeout-minutes: 45
needs: build

steps:
- name: Checkout
uses: actions/checkout@v6

- name: Setup Rust
uses: dtolnay/rust-toolchain@stable

- name: Rust cache
uses: swatinem/rust-cache@v2
with:
workspaces: src-tauri

- name: Setup Node
uses: actions/setup-node@v6
with:
node-version: 24

- name: Setup pnpm
uses: pnpm/action-setup@1e1c8eafbd745f64b1ef30a7d7ed7965034c486c
with:
package_json_file: ./package.json
cache: true
cache_dependency_path: ./pnpm-lock.yaml

- name: Install dependencies
run: pnpm install --frozen-lockfile

- name: Install tauri-driver
run: cargo install tauri-driver --locked

- name: Ensure Edge WebDriver is available
shell: pwsh
run: |
function Add-DriverToPath([string]$driverPath) {
$driverDir = Split-Path $driverPath -Parent
$driverDir | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
Write-Host "Using msedgedriver at: $driverPath"
}

$existingDriver = Get-Command msedgedriver -ErrorAction SilentlyContinue
if ($existingDriver) {
Write-Host "msedgedriver already available on PATH."
msedgedriver --version
exit 0
}

$preinstalledCandidates = @(
"C:\SeleniumWebDrivers\EdgeDriver\msedgedriver.exe",
"C:\WebDriver\msedgedriver.exe"
)

foreach ($candidate in $preinstalledCandidates) {
if (Test-Path $candidate) {
Add-DriverToPath $candidate
exit 0
}
}

$edgeExe = "${env:ProgramFiles(x86)}\Microsoft\Edge\Application\msedge.exe"
if (-not (Test-Path $edgeExe)) {
$edgeExe = "${env:ProgramFiles}\Microsoft\Edge\Application\msedge.exe"
}

if (-not (Test-Path $edgeExe)) {
throw "Could not find Microsoft Edge executable."
}

$edgeVersion = (Get-Item $edgeExe).VersionInfo.ProductVersion
$majorVersion = $edgeVersion.Split('.')[0]
$driverDir = Join-Path $env:RUNNER_TEMP "msedgedriver"
New-Item -ItemType Directory -Path $driverDir -Force | Out-Null
$driverZipPath = Join-Path $driverDir "edgedriver.zip"

$downloadHosts = @(
"https://msedgedriver.azureedge.net",
"https://msedgedriver.microsoft.com"
)

$downloaded = $false
$errors = @()
foreach ($host in $downloadHosts) {
try {
$latestReleaseUrl = "$host/LATEST_RELEASE_$majorVersion"
$driverVersion = (Invoke-WebRequest -Uri $latestReleaseUrl -UseBasicParsing).Content.Trim()
$driverZipUrl = "$host/$driverVersion/edgedriver_win64.zip"

Invoke-WebRequest -Uri $driverZipUrl -OutFile $driverZipPath
Expand-Archive -Path $driverZipPath -DestinationPath $driverDir -Force

$driverBinary = Join-Path $driverDir "msedgedriver.exe"
if (Test-Path $driverBinary) {
Add-DriverToPath $driverBinary
$downloaded = $true
break
}
} catch {
$errors += "${host}: $($_.Exception.Message)"
}
}

if (-not $downloaded) {
throw "Unable to install msedgedriver. Attempts: $($errors -join ' | ')"
}

- name: Verify Edge WebDriver
run: msedgedriver --version

- name: Run system tests
env:
TAURI_TEST_KEEP_DATA: '1'
run: pnpm run test:system

- name: Upload system test artifacts on failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: system-test-artifacts-${{ github.run_id }}
path: |
.wdio-artifacts
.system-test-data
if-no-files-found: ignore
retention-days: 7
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,5 @@ dist-ssr
*.sw?
scripts/

.vs/
.vs/
.system-test-data/
47 changes: 47 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,53 @@ npm run tauri build
pnpm run tauri:windows:build --arch "x64,arm64" --runner pnpm
```

## System Test Mode

For deterministic system-test runs, you can enable test mode:

- `TAURI_TEST_MODE=1` enables deterministic import/export paths and test data isolation.
- `TAURI_TEST_RUN_ID` (optional) isolates each run into its own data subdirectory.
- `TAURI_TEST_EXPORT_PATH` and `TAURI_TEST_IMPORT_PATH` (optional) override deterministic file paths.

Windows PowerShell example:

```powershell
$env:TAURI_TEST_MODE = "1"
$env:TAURI_TEST_RUN_ID = "local-smoke"
pnpm run tauri dev
```

## System Tests (WebdriverIO + tauri-driver)

Run the system suite:

```bash
pnpm run test:system
```

By default, each run uses an isolated data folder and cleans it up when the suite finishes.

Prerequisites:

- Install `tauri-driver`: `cargo install tauri-driver --locked`
- On Windows, ensure `msedgedriver` is installed and version-matched to Edge

Useful env overrides:

- `TAURI_TEST_RUN_ID`: isolate each run's app data folder
- `TAURI_TEST_REUSE_RUN_ID=1`: reuse a fixed `TAURI_TEST_RUN_ID` across runs
- `TAURI_TEST_DATA_ROOT`: custom root folder for system-test data
- `TAURI_TEST_KEEP_DATA=1`: keep run data after test completion (no cleanup)
- `TAURI_DRIVER_PATH`: explicit path to `tauri-driver`
- `TAURI_TEST_EXPORT_PATH` / `TAURI_TEST_IMPORT_PATH`: deterministic transfer file paths

Current automated scenarios (`e2e/specs/system.e2e.mjs`):

- Smoke flow (create board + open/close settings)
- Board lifecycle (create, rename, duplicate)
- Board persistence across app restart (`browser.reloadSession`)
- Settings persistence across app restart (`browser.reloadSession`)

## How It Works

**Board Management**: The app stores board metadata and data in your system's app data directory
Expand Down
133 changes: 133 additions & 0 deletions e2e/helpers/board-ui.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
const SELECTORS = {
createBoardInput: '[data-testid="create-board-input"]',
createBoardSubmit: '[data-testid="create-board-submit"]',
settingsOpenButton: '[data-testid="open-settings-btn"]',
settingsModal: '[data-testid="settings-modal"]',
settingsCloseButton: '[data-testid="close-settings-btn"]',
hideExportRowToggle: '[data-testid="toggle-hide-export-row"]',
boardActionRename: '[data-testid="board-action-rename"]',
boardActionDuplicate: '[data-testid="board-action-duplicate"]',
};

function boardNameXpath(name) {
return `//span[contains(@class, "board-name") and normalize-space()="${name}"]`;
}

function boardMenuButtonXpath(name) {
return `//div[contains(@class, "board-item")][.//span[contains(@class, "board-name") and normalize-space()="${name}"]]//button[contains(@class, "menu-btn")]`;
}

function boardItemXpath(name) {
return `//div[contains(@class, "board-item")][.//span[contains(@class, "board-name") and normalize-space()="${name}"]]`;
}

export function uniqueBoardName(prefix) {
return `${prefix} ${Date.now().toString(36)}`;
}

export async function waitForAppReady() {
const createBoardInput = await $(SELECTORS.createBoardInput);
await createBoardInput.waitForDisplayed({ timeout: 30000 });
}

export async function createBoard(name) {
const createBoardInput = await $(SELECTORS.createBoardInput);
await createBoardInput.waitForDisplayed({ timeout: 30000 });
await createBoardInput.setValue(name);

const createBoardSubmit = await $(SELECTORS.createBoardSubmit);
await createBoardSubmit.click();

await waitForBoardVisible(name);
}

export async function waitForBoardVisible(name) {
const boardName = await $(boardNameXpath(name));
await boardName.waitForDisplayed({ timeout: 10000 });
}

export async function openBoardMenu(name) {
const boardItem = await $(boardItemXpath(name));
await boardItem.waitForDisplayed({ timeout: 10000 });
await boardItem.moveTo();

const menuButton = await $(boardMenuButtonXpath(name));
await menuButton.waitForClickable({ timeout: 10000 });
await menuButton.click();
}

export async function renameBoard(currentName, nextName) {
await openBoardMenu(currentName);

const renameAction = await $(SELECTORS.boardActionRename);
await renameAction.waitForDisplayed({ timeout: 10000 });
await renameAction.click();

const editInput = await $('.edit-input');
await editInput.waitForDisplayed({ timeout: 10000 });
await editInput.clearValue();
await editInput.setValue(nextName);
await browser.keys('Enter');

await waitForBoardVisible(nextName);
}

export async function duplicateBoard(name) {
await openBoardMenu(name);

const duplicateAction = await $(SELECTORS.boardActionDuplicate);
await duplicateAction.waitForDisplayed({ timeout: 10000 });
await duplicateAction.click();

const duplicatedName = `${name} (Copy)`;
await waitForBoardVisible(duplicatedName);
return duplicatedName;
}

export async function openSettings() {
const settingsButton = await $(SELECTORS.settingsOpenButton);
await settingsButton.waitForDisplayed({ timeout: 10000 });
await settingsButton.click();

const settingsModal = await $(SELECTORS.settingsModal);
await settingsModal.waitForDisplayed({ timeout: 10000 });
}

export async function closeSettings() {
const closeButton = await $(SELECTORS.settingsCloseButton);
await closeButton.waitForDisplayed({ timeout: 10000 });
await closeButton.click();

const settingsModal = await $(SELECTORS.settingsModal);
await settingsModal.waitForDisplayed({ reverse: true, timeout: 10000 });
}

export async function setHideExportRow(enabled) {
const toggle = await $(SELECTORS.hideExportRowToggle);
await toggle.waitForExist({ timeout: 10000 });

const selected = await toggle.isSelected();
if (selected !== enabled) {
await browser.execute((element) => {
element.click();
}, toggle);
}

await browser.waitUntil(async () => (await toggle.isSelected()) === enabled, {
timeout: 10000,
timeoutMsg: `Hide export row toggle did not switch to ${enabled}.`,
});
}

export async function assertExportRowHidden() {
const exportRow = await $('.board-export-actions');
const exists = await exportRow.isExisting();
if (exists) {
throw new Error('Expected export row to be hidden, but it is visible.');
}
}

export async function restartAppSession() {
await browser.reloadSession();
await waitForAppReady();
}
Loading
Loading