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
4 changes: 2 additions & 2 deletions .github/workflows/e2e.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ jobs:
- name: Install system dependencies
run: |
apt-get update
apt-get install -y build-essential ca-certificates desktop-file-utils curl git \
apt-get install -y build-essential ca-certificates dbus desktop-file-utils curl git \
iproute2 iputils-ping libayatana-appindicator3-dev libgtk-3-dev libprotobuf-dev \
librsvg2-dev libssl-dev libudev-dev libwebkit2gtk-4.1-dev libxdo-dev patchelf procps \
protobuf-compiler webkit2gtk-driver xvfb wireguard-tools xclip xdg-utils
Expand Down Expand Up @@ -106,7 +106,7 @@ jobs:
working-directory: e2e

- name: Run e2e tests
run: xvfb-run -a pnpm test
run: dbus-run-session -- xvfb-run -a pnpm test
working-directory: e2e

- name: Collect logs
Expand Down
4 changes: 2 additions & 2 deletions e2e/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ Defguard services to be running separately.
- An administrator account whose login does not require MFA. The password is supplied through `e2e/.env`.
- A dedicated deployment with exactly one test network named `e2e`. The connection checks use the first visible connect button.
- Root `defguard-service` running with `/var/run/defguard.socket` available.
- Rust/Cargo with the Tauri CLI, Node.js, `pnpm`, `just`, `WebKitWebDriver`, and `xvfb-run` available.
- Rust/Cargo with the Tauri CLI, Node.js, `pnpm`, `just`, `WebKitWebDriver`, `xvfb-run`, and `dbus-run-session` available.
- `tauri-driver` available at `~/.cargo/bin/tauri-driver`, or at the path set by `TAURI_DRIVER`.

## Configure the test environment
Expand Down Expand Up @@ -58,6 +58,6 @@ To run one spec directly:
```bash
cd e2e
NATIVE_DRIVER="$(command -v WebKitWebDriver)" \
xvfb-run -a pnpm exec wdio run ./wdio.conf.ts \
dbus-run-session -- xvfb-run -a pnpm exec wdio run ./wdio.conf.ts \
--spec=./tests/enrollment.spec.ts
```
84 changes: 84 additions & 0 deletions e2e/helpers/launchConfig.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import fs from "node:fs";
import path from "node:path";

export type LaunchConfig = {
files?: Record<string, string>;
args?: string[];
};

type ProvisioningFixture = { enrollmentUrl: string; enrollmentToken: string };
type DeepLinkFixture = { url: string; token: string };

export const provisioningFixture: ProvisioningFixture = {
enrollmentUrl: "https://enroll.example",
enrollmentToken: "e2e-provisioning-token",
};

export const coldStartFixture: {
provisioning: ProvisioningFixture;
deepLink: DeepLinkFixture;
} = {
provisioning: {
enrollmentUrl: "https://provisioning.example",
enrollmentToken: "e2e-provisioning-token",
},
deepLink: {
url: "https://cold-start.example",
token: "e2e-cold-start-token",
},
};

export const provisioningFile = ({
enrollmentUrl,
enrollmentToken,
}: ProvisioningFixture): string =>
JSON.stringify({
enrollment_url: enrollmentUrl,
enrollment_token: enrollmentToken,
});

export const deepLinkArg = ({ url, token }: DeepLinkFixture): string =>
`defguard://addinstance?token=${encodeURIComponent(token)}&url=${encodeURIComponent(url)}`;

export const launchConfigs: Record<string, LaunchConfig> = {
"deep_link_cold_start.spec.ts": {
files: {
"provisioning.json": provisioningFile(coldStartFixture.provisioning),
},
args: [deepLinkArg(coldStartFixture.deepLink)],
},
"provisioning.spec.ts": {
files: { "provisioning.json": provisioningFile(provisioningFixture) },
},
};

export const getLaunchConfig = (specs: string[]): LaunchConfig | undefined => {
const configured = specs
.map((spec) => path.basename(spec))
.filter((specName) => Object.hasOwn(launchConfigs, specName));
if (configured.length > 0 && specs.length > 1) {
throw new Error(
`Specs with a launch config must run in their own session: ${configured.join(", ")}`,
);
}
return configured.length > 0 ? launchConfigs[configured[0]] : undefined;
};

export const assertLaunchConfigSpecsExist = (testsDir: string): void => {
for (const specName of Object.keys(launchConfigs)) {
if (!fs.existsSync(path.join(testsDir, specName))) {
throw new Error(`Launch config refers to missing spec tests/${specName}`);
}
}
};

export const writeLaunchFiles = (
dataDir: string,
files: Record<string, string>,
): void => {
const appDataDir = path.join(dataDir, "share", "net.defguard");
fs.mkdirSync(appDataDir, { recursive: true });
for (const [fileName, contents] of Object.entries(files)) {
fs.writeFileSync(path.join(appDataDir, fileName), contents);
}
};
22 changes: 22 additions & 0 deletions e2e/tests/deep_link_cold_start.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { $, browser, expect } from "@wdio/globals";
import { coldStartFixture } from "../helpers/launchConfig.js";
import { switchToWindowLabel } from "../helpers/windows.js";

const { token, url } = coldStartFixture.deepLink;

describe("cold-start deep link", () => {
it("prioritizes the deep link over provisioning config", async () => {
await switchToWindowLabel("full-view");
await browser.waitUntil(
async () =>
new URL(await browser.getUrl()).pathname === "/full/add/instance",
{ timeout: 30_000, timeoutMsg: "Cold-start deep link was not handled" },
);

const currentUrl = new URL(await browser.getUrl());
expect(currentUrl.searchParams.get("token")).toBe(token);
expect(currentUrl.searchParams.get("url")).toBe(url);
await expect($("#add-instance-view")).toBeDisplayed();
await expect($('[data-testid="field-url"]')).not.toBeDisplayed();
});
});
49 changes: 49 additions & 0 deletions e2e/tests/deep_link_running_app.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { spawnSync } from "node:child_process";
import { $, browser, expect } from "@wdio/globals";
import { deepLinkArg } from "../helpers/launchConfig.js";
import { switchToWindowLabel } from "../helpers/windows.js";

const token = "e2e-running-app-token";
const url = "https://running.example";

describe("running-app deep link", () => {
it("forwards the link to the running client", async () => {
await switchToWindowLabel("full-view");
await browser.waitUntil(
async () => new URL(await browser.getUrl()).pathname === "/full/add",
{ timeout: 30_000, timeoutMsg: "Full view did not open" },
);
await expect($("#add-page-view")).toBeDisplayed();

const clientBinary = process.env.CLIENT_BINARY;
if (!clientBinary) {
throw new Error("CLIENT_BINARY was not set by the e2e session");
}
if (!process.env.DBUS_SESSION_BUS_ADDRESS) {
throw new Error("The e2e process has no D-Bus session");
}

const secondInstance = spawnSync(
clientBinary,
[deepLinkArg({ token, url })],
{ env: { ...process.env }, stdio: "ignore", timeout: 15_000 },
);
if (secondInstance.error) throw secondInstance.error;
expect(secondInstance.status).toBe(0);
expect(secondInstance.signal).toBeNull();

await browser.waitUntil(
async () =>
new URL(await browser.getUrl()).pathname === "/full/add/instance",
{
timeout: 15_000,
timeoutMsg: "Running client did not handle the deep link",
},
);
const currentUrl = new URL(await browser.getUrl());
expect(currentUrl.searchParams.get("token")).toBe(token);
expect(currentUrl.searchParams.get("url")).toBe(url);
await expect($("#add-instance-view")).toBeDisplayed();
await expect($('[data-testid="field-url"]')).not.toBeDisplayed();
});
});
73 changes: 73 additions & 0 deletions e2e/tests/provisioning.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import fs from "node:fs";
import path from "node:path";
import { $, browser, expect } from "@wdio/globals";
import { invoke } from "../helpers/client.js";
import { provisioningFixture } from "../helpers/launchConfig.js";
import { switchToWindowLabel } from "../helpers/windows.js";

const { enrollmentToken, enrollmentUrl } = provisioningFixture;

describe("provisioning", () => {
it("opens the prefilled instance form from launch config", async () => {
await switchToWindowLabel("full-view");
await browser.waitUntil(
async () =>
new URL(await browser.getUrl()).pathname === "/full/add/instance",
{ timeout: 10_000, timeoutMsg: "Provisioning form did not open" },
);

const currentUrl = new URL(await browser.getUrl());
expect(currentUrl.searchParams.get("token")).toBe(enrollmentToken);
expect(currentUrl.searchParams.get("url")).toBe(enrollmentUrl);
await expect($("#add-instance-view")).toBeDisplayed();
await expect($('[data-testid="field-url"]')).not.toBeDisplayed();
});

it("does not restore provisioning after the saved instance is deleted", async () => {
await switchToWindowLabel("full-view");
const saved = await invoke<{ instance: { id: number } }>(
"save_device_config",
{
privateKey: "e2e-placeholder-private-key",
response: {
device: {
id: 1,
name: "e2e-device",
pubkey: "e2e-placeholder-public-key",
user_id: 1,
created_at: 0,
},
configs: [],
instance: {
id: "00000000-0000-4000-8000-000000000001",
name: "e2e-instance",
url: "https://core.example",
proxy_url: "https://edge.example",
username: "e2e",
},
},
},
);
const dataDir = process.env.E2E_DATA_DIR;
if (!dataDir) throw new Error("E2E_DATA_DIR is required");
expect(
fs.existsSync(
path.join(dataDir, "share", "net.defguard", "provisioning.json"),
),
).toBe(true);

await browser.url("tauri://localhost/full/");
await browser.waitUntil(
async () => new URL(await browser.getUrl()).pathname === "/full/overview",
{ timeout: 10_000, timeoutMsg: "Expected the saved-instance overview" },
);

await invoke("delete_instance", { instanceId: saved.instance.id });
await browser.url("tauri://localhost/full/");
await browser.waitUntil(
async () => new URL(await browser.getUrl()).pathname === "/full/add",
{ timeout: 10_000, timeoutMsg: "Expected the empty-instance add page" },
);
await expect($("#add-instance-view")).not.toBeDisplayed();
});
});
46 changes: 35 additions & 11 deletions e2e/wdio.conf.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@ import { type ChildProcess, spawn, spawnSync } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import {
assertLaunchConfigSpecsExist,
getLaunchConfig,
writeLaunchFiles,
} from "./helpers/launchConfig.js";

const here = import.meta.dirname;

Expand Down Expand Up @@ -85,25 +90,44 @@ export const config: WebdriverIO.Config = {
"`pnpm tauri build` or set CLIENT_BINARY.",
);
}
assertLaunchConfigSpecsExist(path.join(here, "tests"));
},

beforeSession: () => {
beforeSession: (_config, caps, specs) => {
killLeftoverClients();
cleanupWireguardInterfaces();
dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "defguard-e2e-"));
const launchConfig = getLaunchConfig(specs);
const sessionEnv = {
CLIENT_BINARY: clientBinary,
DEFGUARD_CLIENT_WELCOME_SKIP: "1",
E2E_DATA_DIR: dataDir,
XDG_DATA_HOME: path.join(dataDir, "share"),
XDG_CONFIG_HOME: path.join(dataDir, "config"),
XDG_CACHE_HOME: path.join(dataDir, "cache"),
};
Object.assign(process.env, sessionEnv);

if (launchConfig?.files) {
writeLaunchFiles(dataDir, launchConfig.files);
}

if (launchConfig?.args) {
const tauriOptions = caps["tauri:options"] as
| { args?: string[] }
| undefined;
if (!tauriOptions) {
throw new Error(
"Missing tauri:options capability for launch arguments",
);
}
tauriOptions.args = launchConfig.args;
}

tauriDriver = spawn(
tauriDriverBinary,
nativeDriver ? ["--native-driver", nativeDriver] : [],
{
stdio: ["ignore", "inherit", "inherit"],
env: {
...process.env,
DEFGUARD_CLIENT_WELCOME_SKIP: "1",
XDG_DATA_HOME: path.join(dataDir, "share"),
XDG_CONFIG_HOME: path.join(dataDir, "config"),
XDG_CACHE_HOME: path.join(dataDir, "cache"),
},
},
{ stdio: ["ignore", "inherit", "inherit"] },
);
tauriDriver.on("error", (error) => {
console.error("tauri-driver failed to start:", error);
Expand Down
5 changes: 3 additions & 2 deletions justfile
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,10 @@ e2e-test:
test "$(uname -s)" = "Linux" || { echo "Client E2E tests require Linux." >&2; exit 1; }
test -f e2e/.env || { echo "Missing e2e/.env; copy e2e/.env.example and fill it locally." >&2; exit 1; }
test -S /var/run/defguard.socket || { echo "defguard-service is not running at /var/run/defguard.socket." >&2; exit 1; }
test -x "${TAURI_DRIVER:-$HOME/.cargo/bin/tauri-driver}" || { echo "tauri-driver is missing; install it with: cargo install tauri-driver --locked" >&2; exit 1; }
test -x "${TAURI_DRIVER:-$HOME/.cargo/bin/tauri-driver}" || { echo "tauri-driver is missing; set TAURI_DRIVER to its location." >&2; exit 1; }
command -v WebKitWebDriver >/dev/null || { echo "WebKitWebDriver is missing; enter nix develop." >&2; exit 1; }
command -v xvfb-run >/dev/null || { echo "xvfb-run is missing; enter nix develop." >&2; exit 1; }
cd e2e && NATIVE_DRIVER="${NATIVE_DRIVER:-$(command -v WebKitWebDriver)}" xvfb-run -a pnpm test
command -v dbus-run-session >/dev/null || { echo "dbus-run-session is missing; enter nix develop." >&2; exit 1; }
cd e2e && NATIVE_DRIVER="${NATIVE_DRIVER:-$(command -v WebKitWebDriver)}" dbus-run-session -- xvfb-run -a pnpm test

e2e: e2e-build e2e-provision e2e-test
Loading
Loading