Skip to content
Open
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
20 changes: 19 additions & 1 deletion .github/workflows/_test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ jobs:
working-directory: webapp
install: false
start: >-
node --experimental-strip-types cypress/support/federated.ts,
node --experimental-strip-types cypress/support/training/federated/peer.ts,
pnpm exec vite --mode test --port 1351 --strictPort
wait-on: "http://localhost:8080, http://localhost:1351"
spec: cypress/e2e/training/federated/${{ matrix.task }}.cy.ts
Expand All @@ -156,6 +156,24 @@ jobs:
path: webapp/cypress/screenshots
if-no-files-found: ignore

test-e2e-decentralized:
name: decentralized webapp (mnist)
runs-on: ubuntu-latest
timeout-minutes: 10

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
timeout-minutes: 10
timeout-minutes: 12

The current timeouts add up to ~8min + 4 browser startut, I think 10min is a bit tight and we want to leave enough time to upload the screenshots if it fails

steps:
- uses: actions/checkout@v7
- uses: ./.github/actions/setup-node
- uses: ./.github/actions/pull-dataset-artifacts
- uses: actions/download-artifact@v8
with: { name: all-builds }
- run: xvfb-run --auto-servernum pnpm -F webapp run test:e2e:decentralized
- uses: actions/upload-artifact@v7
if: failure()
with:
name: decentralized-mnist-screenshots
path: webapp/cypress/screenshots
if-no-files-found: ignore

test-cli:
runs-on: ubuntu-latest
steps:
Expand Down
4 changes: 3 additions & 1 deletion .knip.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@
"webapp": {
"entry": [
"cypress/e2e/training/federated/*.cy.ts",
"cypress/support/federated.ts"
"cypress/support/training/federated/peer.ts",
"cypress/e2e/training/decentralized/*.cy.ts",
"cypress/support/training/decentralized/browsers.ts"
],
"ignoreDependencies": ["buffer"]
},
Expand Down
2 changes: 1 addition & 1 deletion discojs/src/default_tasks/mnist.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ export const mnist: TaskProvider<"image", "decentralized"> = {
beta: 0.9,
},
},
minNbOfParticipants: 3,
minNbOfParticipants: 4,
maxShareValue: 100,
tensorBackend: "tfjs",
maxConnectionRetry: 3,
Expand Down
6 changes: 4 additions & 2 deletions docs/CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,10 +120,12 @@ the workspace with `pnpm -r run build`, and fetch the datasets with

```sh
pnpm -F webapp run test:e2e:local
pnpm -F webapp run test:e2e:decentralized
```

This runs both Titanic and LUS COVID with a mocked server. The command starts
and stops Vite itself; port 1351 must be available.
The local command runs both Titanic and LUS COVID with a mocked server.
The decentralized command runs MNIST across Chrome and Firefox participants with a real server; install Chrome and Firefox (plus `xvfb` and `xauth` on headless Linux).

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
The decentralized command runs MNIST across Chrome and Firefox participants with a real server; install Chrome and Firefox (plus `xvfb` and `xauth` on headless Linux).
The decentralized command runs MNIST across Chrome, Edge and Firefox participants with a real server; install Chrome, Edge and Firefox (plus `xvfb` and `xauth` on headless Linux).

Both commands start and stop their servers and Vite automatically.

Federated training tests run in separate CI jobs for Titanic and LUS COVID.
Each job starts a real server and a Node participant configured for its task;
Expand Down
43 changes: 43 additions & 0 deletions webapp/cypress/e2e/training/decentralized/mnist.cy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import {
assertNoErrorToast,
goToTaskOverview,
} from "../../../support/training";

describe("decentralized webapp training", () => {
it("trains MNIST across Chrome, Edge, and Firefox participants", () => {
cy.env(["expectedBrowser"])
.its("expectedBrowser")
.should("equal", Cypress.browser.name);

goToTaskOverview("mnist");
cy.contains("button", "next").click();

cy.contains("button", "group").click();
cy.task<string[]>("readdir", "../datasets/CIFAR10").then((files) =>

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
cy.task<string[]>("readdir", "../datasets/CIFAR10").then((files) =>
// Train on dummy images from CIFAR10, this test doesn't care about actual performance
cy.task<string[]>("readdir", "../datasets/CIFAR10").then((files) =>

cy
.contains("h4", "Group label: 0") // dummy labels for now
.parents()
.eq(1)
.find('[data-testid="select-image-button"]')
.selectFile(files),
);
cy.contains("button", "next").click();
cy.contains("button", "collaboratively").click();
cy.contains("button", "Start training").click();
assertNoErrorToast();

cy.env(["numberOfParticipants"]).then(({ numberOfParticipants = 4 }) => {
cy.contains("h6", "number of participants")
.next({ timeout: 120_000 })
.should("have.text", `${numberOfParticipants}`);
});
cy.contains("h6", "epochs")
.next({ timeout: 270_000 })
.should("have.text", "20 / 20");
cy.contains("h6", "Collaborative model sharing")
.next({ timeout: 60_000 })
.should("have.text", "10");
cy.contains("Training successfully completed", { timeout: 30_000 });
assertNoErrorToast();
});
});
2 changes: 1 addition & 1 deletion webapp/cypress/e2e/training/local/lus_covid.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ import { setupServerWith } from "../../../support/e2e";
import {
assertNoErrorToast,
goToTaskOverview,
trainLocallyAndSave,
withTrainingConfig,
} from "../../../support/training";
import { trainLocallyAndSave } from "../../../support/training/local";

const numEpochs = 4;
const lusCovidTask = withTrainingConfig(defaultTasks.lusCovid, {
Expand Down
6 changes: 2 additions & 4 deletions webapp/cypress/e2e/training/local/titanic.cy.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
import { defaultTasks } from "@epfml/discojs";

import { setupServerWith } from "../../../support/e2e";
import {
goToTaskOverview,
trainLocallyAndSave,
} from "../../../support/training";
import { goToTaskOverview } from "../../../support/training";
import { trainLocallyAndSave } from "../../../support/training/local";

it("completes local Titanic training and saves the model", () => {
const titanicTask = defaultTasks.titanic;
Expand Down
61 changes: 0 additions & 61 deletions webapp/cypress/support/training.ts

This file was deleted.

87 changes: 87 additions & 0 deletions webapp/cypress/support/training/decentralized/browsers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
import { createInterface } from "node:readline";

const browsers = ["chrome", "chrome", "edge", "firefox"];

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You mentioned it would be a better idea to swap out a chrome for edge rather than a firefox. Did someting make you change your mind?
Is this linked to the commit mentioning a "geckodriver race condition"?


const children: ChildProcessWithoutNullStreams[] = [];

function stopChildren(): void {
for (const child of children) {
if (child.pid === undefined || child.exitCode !== null) continue;
try {
process.kill(-child.pid, "SIGTERM"); // minus sign means kill both cypress and its browser
} catch (error) {
if (
!(error instanceof Error && "code" in error && error.code === "ESRCH")
)
throw error;
}
}
}

for (const signal of ["SIGINT", "SIGTERM"])
process.once(signal, () => {
stopChildren();
process.exitCode = 1;
});

let firstFailedBrowser: string | undefined;

const results = browsers.map((browser, index) => {
Comment thread
ale18V marked this conversation as resolved.
const label = `${browser}-${index + 1}`;
const child = spawn(
"pnpm",
[
"exec",
"cypress",
"run",
"--e2e",
"--browser",
browser,
"--spec",
"cypress/e2e/training/decentralized/mnist.cy.ts",
"--config",
`screenshotsFolder=cypress/screenshots/${label}`,
"--env",
`expectedBrowser=${browser},numberOfParticipants=${browsers.length}`,
],
{
detached: true,
stdio: "pipe",
env: {
...process.env,
DISCO_TRAINING_E2E: "1",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DISCO_TRAINING_E2E is already set in process.env isn't it? Do you need to set it again here?

},
},
);
child.stdin.end();
children.push(child);
for (const output of [child.stdout, child.stderr])
createInterface({ input: output }).on("line", (line) =>
console.log(`[${label}] ${line}`),
);

return new Promise<boolean>((resolve) => {
child.once("error", (error) => {
firstFailedBrowser ??= label;
console.error(`[${label}] ${error}`);
stopChildren();
resolve(false);
});
child.once("exit", (code, signal) => {
const passed = code === 0;
if (!passed) {
firstFailedBrowser ??= label;
console.error(`[${label}] exited with ${code ?? signal}`);
stopChildren();
}
Comment thread
ale18V marked this conversation as resolved.
resolve(passed);
});
});
});

if ((await Promise.all(results)).some((passed) => !passed)) {
if (firstFailedBrowser !== undefined)
console.error(`First browser to fail: ${firstFailedBrowser}`);
process.exitCode = 1;
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import type { DataFormat, DataType, TaskProvider } from "@epfml/discojs";
import { loadCSV, loadImage } from "@epfml/discojs-node";
import { Repeat } from "immutable";
import { Server } from "server";
import { withTrainingConfig } from "./training.ts";
import { withTrainingConfig } from "../index.ts";

const taskName = process.env.DISCO_E2E_FEDERATED_TASK ?? "titanic";
if (taskName !== "titanic" && taskName !== "lus_covid") {
Expand Down Expand Up @@ -64,13 +64,16 @@ try {
await train(
defaultTasks.titanic,
loadCSV(
path.join(import.meta.dirname, "../../../datasets/titanic_train.csv"),
path.join(
import.meta.dirname,
"../../../../../datasets/titanic_train.csv",
),
),
);
} else {
const folder = path.join(
import.meta.dirname,
"../../../datasets/lus_covid",
"../../../../../datasets/lus_covid",
);
const positive = (await nodeHalfOfImages(path.join(folder, "COVID+"))).zip(
Repeat("COVID-Positive"),
Expand Down
42 changes: 42 additions & 0 deletions webapp/cypress/support/training/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import type {
DataType,
Network,
TaskProvider,
TrainingInformation,
} from "@epfml/discojs";

export function withTrainingConfig<D extends DataType, N extends Network>(
provider: TaskProvider<D, N>,
trainingConfig: Pick<TrainingInformation<D, N>, "epochs" | "roundDuration">,
): TaskProvider<D, N> {
return {
modelCard: provider.modelCard,
async getTask() {
const task = await provider.getTask();
return {
...task,
trainingInformation: {
...task.trainingInformation,
...trainingConfig,
},
};
},
};
}

export function goToTaskOverview(taskId?: string): void {
cy.visit("/");
cy.contains("a", "Start training").click();
cy.get(".driver-popover-close-btn").click();
if (taskId !== undefined) {
cy.get(`#${taskId}`, { timeout: 30_000 })
.contains("button", "participate")
.click();
} else {
cy.contains("button", "participate").click();
}
}

export function assertNoErrorToast(): void {
cy.get(".v-toast__item--error", { timeout: 1_000 }).should("not.exist");
}
26 changes: 26 additions & 0 deletions webapp/cypress/support/training/local/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { assertNoErrorToast } from "../index";

export function trainLocallyAndSave(
title: string,
epochs: number,
trainingTimeout = 120_000,
): void {
cy.contains("button", "next").click();
cy.contains("button", "locally").click();
cy.contains("button", "Start training").click();
assertNoErrorToast();
cy.contains("Training successfully completed", {
timeout: trainingTimeout,
}).should("be.visible");
assertNoErrorToast();
cy.contains("h6", "epochs")
.next()
.should("have.text", `${epochs} / ${epochs}`);
cy.contains("button", "Start training").should("be.visible");
cy.contains("button", "next").click();
cy.contains("button", "save model").click();
cy.contains(`The trained ${title} model has been saved.`, {
timeout: 30_000,
}).should("be.visible");
assertNoErrorToast();
}
1 change: 1 addition & 0 deletions webapp/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"test:e2e": "VITE_SERVER_URL=http://server start-server-and-test \"vite\" http://localhost:1351 'pnpm exec cypress run --e2e'",
"test:e2e:local": "DISCO_TRAINING_E2E=1 VITE_SERVER_URL=http://server start-server-and-test 'vite --mode test --port 1351 --strictPort' http://localhost:1351 'pnpm exec cypress run --e2e --spec \"cypress/e2e/training/local/*.cy.ts\"'",
"test:e2e:scripted": "DISCO_SCRIPTED_SERVER_E2E=1 VITE_SERVER_URL=http://server:8080 start-server-and-test 'vite --mode test --port 1351 --strictPort' http://localhost:1351 'pnpm exec cypress run --e2e --spec \"cypress/e2e/training/scripted/*.cy.ts\"'",
"test:e2e:decentralized": "DISCO_TRAINING_E2E=1 VITE_SERVER_URL=http://localhost:8080 start-server-and-test 'node ../server/dist/main.js' http://localhost:8080 'vite --mode test --port 1351 --strictPort' http://localhost:1351 'node --experimental-strip-types cypress/support/training/decentralized/browsers.ts'",
"test:e2e-interactive": "VITE_SERVER_URL=http://server start-server-and-test \"pnpm start\" http://localhost:1351 'pnpm exec cypress open --e2e'"
},
"dependencies": {
Expand Down
Loading