mirror of
https://seed.radicle.dev/rad:z4V1sjrXqjvFdnCUbxPFqd5p4DtH5.git
synced 2026-11-16 03:40:37 +08:00
Give each e2e test its own reliable peer
The page always talked to the shared palm backend, so tests couldn't set up peers of their own. Peers now have no passphrase by default, so tests don't need an ssh-agent and test keys stay out of the developer's agent. Fixtures wait for replication instead of relying on gossip timing, and a request cancelled by a reload no longer counts as a page error.
This commit is contained in:
@@ -28,7 +28,8 @@ jobs:
|
||||
libayatana-appindicator3-dev \
|
||||
librsvg2-dev \
|
||||
libwebkit2gtk-4.1-dev \
|
||||
libjavascriptcoregtk-4.1-dev
|
||||
libjavascriptcoregtk-4.1-dev \
|
||||
openssh-client
|
||||
- run: npm ci
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import {
|
||||
expect,
|
||||
gitOptions,
|
||||
test,
|
||||
useBackend,
|
||||
} from "@tests/support/fixtures.js";
|
||||
|
||||
test("unlocks encrypted keys through the ssh-agent", async ({
|
||||
page,
|
||||
peerManager,
|
||||
sshAuthSock,
|
||||
}) => {
|
||||
const peer = await peerManager.createPeer({
|
||||
name: "locked",
|
||||
gitOptions: gitOptions["bob"],
|
||||
passphrase: "asdf",
|
||||
sshAuthSock,
|
||||
});
|
||||
await peer.logOut();
|
||||
await peer.startHttpd();
|
||||
await useBackend(page, peer);
|
||||
|
||||
await page.goto("/");
|
||||
await expect(page.getByText("Unlock keys")).toBeVisible();
|
||||
|
||||
await page
|
||||
.getByPlaceholder("Enter passphrase to unlock your keys")
|
||||
.fill("wrong");
|
||||
await page.getByRole("button", { name: "Unlock" }).click();
|
||||
await expect(
|
||||
page.getByText("Not able to decrypt keys with provided passphrase."),
|
||||
).toBeVisible();
|
||||
|
||||
await page
|
||||
.getByPlaceholder("Enter passphrase to unlock your keys")
|
||||
.fill("asdf");
|
||||
await page.getByRole("button", { name: "Unlock" }).click();
|
||||
await expect(page.getByText("Unlock keys")).toBeHidden();
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
|
||||
import { expect, test } from "@tests/support/fixtures.js";
|
||||
import { expect, reload, test } from "@tests/support/fixtures.js";
|
||||
|
||||
function backButton(page: Page) {
|
||||
return page.getByRole("button", { name: "Back", exact: true });
|
||||
@@ -88,7 +88,7 @@ test("history position survives a reload", async ({ page }) => {
|
||||
await openRepo(page);
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
await page.reload();
|
||||
await reload(page);
|
||||
await expectRepo(page);
|
||||
await expect(backButton(page)).toBeEnabled();
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { expect, test } from "@tests/support/fixtures.js";
|
||||
|
||||
test("shows whether the node is running", async ({ page, peer }) => {
|
||||
await page.goto("/");
|
||||
await expect(page.getByRole("button", { name: "Online" })).toBeVisible();
|
||||
|
||||
await peer.stopNode();
|
||||
await expect(page.getByRole("button", { name: "Offline" })).toBeVisible();
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect, test } from "@tests/support/fixtures.js";
|
||||
import { expect, reload, test } from "@tests/support/fixtures.js";
|
||||
|
||||
test("default theme", async ({ page }) => {
|
||||
await page.goto("/repos");
|
||||
@@ -14,7 +14,7 @@ test("theme persistence", async ({ page }) => {
|
||||
await page.getByRole("button", { name: "Light", exact: true }).click();
|
||||
await expect(page.locator("html")).toHaveAttribute("data-theme", "light");
|
||||
|
||||
await page.reload();
|
||||
await reload(page);
|
||||
|
||||
await expect(page.locator("html")).toHaveAttribute("data-theme", "light");
|
||||
});
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
import type { PeerManager, RadiclePeer } from "./peerManager.js";
|
||||
import type { Page } from "@playwright/test";
|
||||
|
||||
import * as Fs from "node:fs/promises";
|
||||
import * as Os from "node:os";
|
||||
import * as Path from "node:path";
|
||||
import type * as Stream from "node:stream";
|
||||
|
||||
@@ -13,6 +15,7 @@ import { createPeerManager } from "@tests/support/peerManager.js";
|
||||
import { createRepo } from "@tests/support/repo.js";
|
||||
import { createOptions, supportDir, tmpDir } from "@tests/support/support.js";
|
||||
import { execa } from "execa";
|
||||
import waitOn from "wait-on";
|
||||
|
||||
import { formatOid } from "@app/lib/utils.js";
|
||||
|
||||
@@ -26,6 +29,7 @@ export const test = base.extend<{
|
||||
stateDir: string;
|
||||
peerManager: PeerManager;
|
||||
peer: RadiclePeer;
|
||||
sshAuthSock: string;
|
||||
outputLog: Stream.Writable;
|
||||
}>({
|
||||
forAllTests: [
|
||||
@@ -48,6 +52,12 @@ export const test = base.extend<{
|
||||
|
||||
if (!process.env.CONTINUE_ON_ERRORS) {
|
||||
page.on("pageerror", msg => {
|
||||
if (tearingDown.has(page)) {
|
||||
return;
|
||||
}
|
||||
if (reloading.has(page) && isCancelledFetch(msg)) {
|
||||
return;
|
||||
}
|
||||
expect(
|
||||
false,
|
||||
`Test failed because there was a console error in the app: ${msg}`,
|
||||
@@ -96,16 +106,19 @@ export const test = base.extend<{
|
||||
await logFile.close();
|
||||
},
|
||||
|
||||
peerManager: async ({ stateDir, outputLog }, use) => {
|
||||
peerManager: async ({ stateDir, outputLog, page }, use) => {
|
||||
const peerManager = await createPeerManager({
|
||||
dataDir: Path.resolve(Path.join(stateDir, "peers")),
|
||||
outputLog,
|
||||
});
|
||||
await use(peerManager);
|
||||
// The page outlives the peers and keeps polling them, so its requests
|
||||
// fail once they shut down. The test is already over by then.
|
||||
tearingDown.add(page);
|
||||
await peerManager.shutdown();
|
||||
},
|
||||
|
||||
peer: async ({ peerManager }, use) => {
|
||||
peer: async ({ page, peerManager }, use) => {
|
||||
const peer = await peerManager.createPeer({
|
||||
name: "httpd",
|
||||
gitOptions: gitOptions["bob"],
|
||||
@@ -113,10 +126,25 @@ export const test = base.extend<{
|
||||
|
||||
await peer.startNode();
|
||||
await peer.startHttpd();
|
||||
await useBackend(page, peer);
|
||||
|
||||
await use(peer);
|
||||
},
|
||||
|
||||
// eslint-disable-next-line no-empty-pattern
|
||||
sshAuthSock: async ({}, use) => {
|
||||
const dir = await Fs.mkdtemp(Path.join(Os.tmpdir(), "radicle-ssh-agent-"));
|
||||
const socket = Path.join(dir, "agent.sock");
|
||||
const agent = execa("ssh-agent", ["-D", "-a", socket]);
|
||||
agent.catch(() => undefined);
|
||||
await waitOn({ resources: [`socket:${socket}`], timeout: 5000 });
|
||||
|
||||
await use(socket);
|
||||
|
||||
agent.kill();
|
||||
await Fs.rm(dir, { recursive: true, force: true });
|
||||
},
|
||||
|
||||
// eslint-disable-next-line no-empty-pattern
|
||||
stateDir: async ({}, use, testInfo) => {
|
||||
const stateDir = testInfo.outputDir;
|
||||
@@ -133,6 +161,36 @@ export const test = base.extend<{
|
||||
},
|
||||
});
|
||||
|
||||
const reloading = new WeakSet<Page>();
|
||||
const tearingDown = new WeakSet<Page>();
|
||||
|
||||
// WebKit reports a fetch that was cancelled because the page went away as an
|
||||
// access control failure.
|
||||
function isCancelledFetch(error: Error) {
|
||||
return /Fetch API cannot load .* due to access control checks/.test(
|
||||
error.message + error.stack,
|
||||
);
|
||||
}
|
||||
|
||||
export async function reload(page: Page) {
|
||||
reloading.add(page);
|
||||
try {
|
||||
await page.reload();
|
||||
// WebKit reports fetches the old page had in flight after the new one
|
||||
// has loaded, so keep ignoring them until the network settles.
|
||||
await page.waitForLoadState("networkidle");
|
||||
} finally {
|
||||
reloading.delete(page);
|
||||
}
|
||||
}
|
||||
|
||||
// Call before the first `page.goto`.
|
||||
export async function useBackend(page: Page, peer: RadiclePeer) {
|
||||
await page.addInitScript(port => {
|
||||
window.__TEST_HTTP_API_PORT__ = port;
|
||||
}, peer.httpdBaseUrl.port);
|
||||
}
|
||||
|
||||
function log(text: string, label: string, outputLog: Stream.Writable) {
|
||||
const output = text
|
||||
.split("\n")
|
||||
@@ -390,6 +448,19 @@ export async function createCobsFixture(
|
||||
["patch", "review", patchThree, "-m", "This looks better", "--accept"],
|
||||
createOptions(repoFolder, 2),
|
||||
);
|
||||
// Don't let palm act on the patch before it has eve's review.
|
||||
await peer.waitForEvent(
|
||||
event =>
|
||||
event.type === "refsFetched" &&
|
||||
event.rid === rid &&
|
||||
event.remote === eve.nodeId &&
|
||||
event.updated.some(update =>
|
||||
Object.values(update).some(ref =>
|
||||
ref.name.endsWith(`/xyz.radicle.patch/${patchThree}`),
|
||||
),
|
||||
),
|
||||
10_000,
|
||||
);
|
||||
await Fs.appendFile(
|
||||
Path.join(repoFolder, "README.md"),
|
||||
"\n\nHad to push a new revision",
|
||||
|
||||
@@ -51,6 +51,10 @@ export default async function globalSetup(): Promise<() => void> {
|
||||
"./crates/test-http-api/Cargo.toml",
|
||||
]);
|
||||
|
||||
// Keeps test keys out of the developer's ssh-agent.
|
||||
delete process.env.SSH_AUTH_SOCK;
|
||||
delete process.env.SSH_AGENT_PID;
|
||||
|
||||
const peerManager = await createPeerManager({
|
||||
dataDir: Path.resolve(tmpDir, "peers"),
|
||||
outputLog: Fs.createWriteStream(
|
||||
|
||||
@@ -60,20 +60,23 @@ export interface RoutingEntry {
|
||||
rid: string;
|
||||
}
|
||||
|
||||
interface PeerManagerParams {
|
||||
dataPath: string;
|
||||
radSeed: string;
|
||||
interface PeerOptions {
|
||||
// Name for easy identification. Used on file system and in logs.
|
||||
name: string;
|
||||
gitOptions?: Record<string, string>;
|
||||
// Without one the keys are unencrypted and need no ssh-agent.
|
||||
passphrase?: string;
|
||||
sshAuthSock?: string;
|
||||
}
|
||||
|
||||
interface PeerManagerParams extends PeerOptions {
|
||||
dataPath: string;
|
||||
radSeed: string;
|
||||
outputLog: Stream.Writable;
|
||||
}
|
||||
|
||||
export interface PeerManager {
|
||||
createPeer(params: {
|
||||
name: string;
|
||||
gitOptions?: Record<string, string>;
|
||||
}): Promise<RadiclePeer>;
|
||||
createPeer(params: PeerOptions): Promise<RadiclePeer>;
|
||||
/**
|
||||
* Kill all processes spawned by any of the peers
|
||||
*/
|
||||
@@ -101,8 +104,7 @@ export async function createPeerManager(createParams: {
|
||||
async createPeer(params) {
|
||||
const peer = await RadiclePeer.create({
|
||||
dataPath: createParams.dataDir,
|
||||
name: params.name,
|
||||
gitOptions: params.gitOptions,
|
||||
...params,
|
||||
radSeed: Array(64)
|
||||
.fill((peers.length + 1).toString())
|
||||
.join(""),
|
||||
@@ -152,6 +154,8 @@ export class RadiclePeer {
|
||||
#eventRecords: NodeEvent[] = [];
|
||||
#outputLog: Stream.Writable;
|
||||
#gitOptions?: Record<string, string>;
|
||||
#passphrase: string;
|
||||
#sshAuthSock?: string;
|
||||
#listenSocketAddr?: string;
|
||||
#httpdBaseUrl?: BaseUrl;
|
||||
#nodeProcess?: SpawnResult;
|
||||
@@ -168,6 +172,8 @@ export class RadiclePeer {
|
||||
radHome: string;
|
||||
logFile: Stream.Writable;
|
||||
name: string;
|
||||
passphrase: string;
|
||||
sshAuthSock?: string;
|
||||
}) {
|
||||
this.checkoutPath = props.checkoutPath;
|
||||
this.nodeId = props.nodeId;
|
||||
@@ -177,13 +183,20 @@ export class RadiclePeer {
|
||||
this.#radHome = props.radHome;
|
||||
this.#outputLog = props.logFile;
|
||||
this.#name = props.name;
|
||||
this.#passphrase = props.passphrase;
|
||||
this.#sshAuthSock = props.sshAuthSock;
|
||||
}
|
||||
|
||||
public async waitForEvent(searchEvent: NodeEvent, timeoutInMs: number) {
|
||||
public async waitForEvent(
|
||||
searchEvent: NodeEvent | ((event: NodeEvent) => boolean),
|
||||
timeoutInMs: number,
|
||||
) {
|
||||
const start = new Date().getTime();
|
||||
const predicate =
|
||||
typeof searchEvent === "function" ? searchEvent : matches(searchEvent);
|
||||
|
||||
while (true) {
|
||||
if (this.#eventRecords.find(matches(searchEvent))) {
|
||||
if (this.#eventRecords.find(predicate)) {
|
||||
return;
|
||||
}
|
||||
if (new Date().getTime() - start > timeoutInMs) {
|
||||
@@ -204,6 +217,8 @@ export class RadiclePeer {
|
||||
gitOptions,
|
||||
radSeed: node,
|
||||
outputLog: logFile,
|
||||
passphrase = "",
|
||||
sshAuthSock,
|
||||
}: PeerManagerParams): Promise<RadiclePeer> {
|
||||
const checkoutPath = Path.join(dataPath, name, "copy");
|
||||
await Fs.mkdir(checkoutPath, { recursive: true });
|
||||
@@ -219,9 +234,10 @@ export class RadiclePeer {
|
||||
const env = {
|
||||
...gitOptions,
|
||||
RAD_HOME: radHome,
|
||||
RAD_PASSPHRASE: "asdf",
|
||||
RAD_PASSPHRASE: passphrase,
|
||||
RAD_KEYGEN_SEED: node,
|
||||
RAD_SOCKET: socket,
|
||||
SSH_AUTH_SOCK: sshAuthSock,
|
||||
};
|
||||
/* eslint-enable @typescript-eslint/naming-convention */
|
||||
|
||||
@@ -237,6 +253,8 @@ export class RadiclePeer {
|
||||
radHome,
|
||||
logFile,
|
||||
name,
|
||||
passphrase,
|
||||
sshAuthSock,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -399,7 +417,8 @@ export class RadiclePeer {
|
||||
GIT_CONFIG_GLOBAL: "/dev/null",
|
||||
GIT_CONFIG_NOSYSTEM: "1",
|
||||
RAD_HOME: this.#radHome,
|
||||
RAD_PASSPHRASE: "asdf",
|
||||
RAD_PASSPHRASE: this.#passphrase,
|
||||
SSH_AUTH_SOCK: this.#sshAuthSock,
|
||||
RAD_LOCAL_TIME: "1671125284",
|
||||
RAD_KEYGEN_SEED: this.#radSeed,
|
||||
RAD_SOCKET: this.#socket,
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
|
||||
import { expect } from "@tests/support/fixtures.js";
|
||||
import { expect, reload } from "@tests/support/fixtures.js";
|
||||
|
||||
// Reloads the current page and verifies that the URL stays correct
|
||||
export const expectUrlPersistsReload = async (page: Page) => {
|
||||
const url = page.url();
|
||||
await page.reload();
|
||||
await reload(page);
|
||||
await expect(page).toHaveURL(url);
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user