Repository navigation
test(webapp): cover decentralized browser training across browsers #1239
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
2604193
b93767a
a49571d
e74568a
1b4816a
4549764
161794e
a84faef
0f24e2d
c7aec64
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -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). | ||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
| 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; | ||||||
|
|
||||||
| 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) => | ||||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||
| 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(); | ||||||||
| }); | ||||||||
| }); | ||||||||
This file was deleted.
| 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"]; | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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? |
||
|
|
||
| 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) => { | ||
|
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", | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| }, | ||
| }, | ||
| ); | ||
| 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(); | ||
| } | ||
|
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 |
|---|---|---|
| @@ -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"); | ||
| } |
| 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(); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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