diff --git a/datasets/populate b/datasets/populate index 17a7d482c..41156afc0 100755 --- a/datasets/populate +++ b/datasets/populate @@ -10,10 +10,8 @@ curl 'https://storage.googleapis.com/deai-313515.appspot.com/example_training_da # lungs ultrasound mkdir -p lus_covid -curl 'https://drive.switch.ch/index.php/s/zM5ZrUWK3taaIly/download' > archive.zip -ln -fs lus_covid DeAI-testimages # redirect top level dir -unzip -u archive.zip -rm archive.zip DeAI-testimages +curl 'https://storage.googleapis.com/deai-313515.appspot.com/lus_covid.tar.gz'| + tar -xz # wikitext mkdir -p wikitext diff --git a/discojs/src/client/client.ts b/discojs/src/client/client.ts index 1d23b559f..f1b61c755 100644 --- a/discojs/src/client/client.ts +++ b/discojs/src/client/client.ts @@ -167,6 +167,8 @@ export abstract class Client extends EventEmitter<{ if (this.#previousStatus !== undefined) this.emit("status", this.#previousStatus); this.nbOfParticipants = event.nbOfParticipants; + // Make sure to set the promise back to undefined + this.promiseForMoreParticipants = undefined; resolve(); }); }); @@ -181,8 +183,6 @@ export abstract class Client extends EventEmitter<{ ); this.emit("status", "not enough participants"); await this.promiseForMoreParticipants; - // Make sure to set the promise back to undefined once resolved - this.promiseForMoreParticipants = undefined; } } /** diff --git a/discojs/src/client/decentralized/README.md b/discojs/src/client/decentralized/README.md new file mode 100644 index 000000000..3819a1fdc --- /dev/null +++ b/discojs/src/client/decentralized/README.md @@ -0,0 +1,59 @@ +# Decentralized Event flow + +```mermaid +sequenceDiagram + autonumber + participant T as Trainer + participant C as DecentralizedClient + participant S as Server + participant P as Peers + + rect rgb(240,240,240) + Note over T,S: 1. Connecting + T->>C: connect() + C->>S: WebSocket connect + ClientConnected + S-->>C: NewDecentralizedNodeInfo { id, nbOfParticipants, waitForMoreParticipants } + C-->>T: base model + end + + rect rgb(240,240,240) + Note over T,S: 2. Round begin + T->>C: onRoundBeginCommunication() + C->>S: JoinRound + Note over C: status "local training" + T->>T: local training + end + + rect rgb(240,240,240) + Note over T,P: 3. Round end, server barrier + T->>C: onRoundEndCommunication(weights) + Note over C: status "waiting for peers to share weights" + C->>S: PeerIsReady + S-->>C: PeersForRound { peers, aggregationRound } + Note over C: status "connecting to peers" + end + + rect rgb(240,240,240) + Note over C,P: 4. Peer connections + C->>S: SignalForPeer { peer, offer/answer/candidate } + S->>P: SignalForPeer (forwarded) + P-->>C: SignalForPeer (forwarded back) + Note over C,P: WebRTC data channel open + end + + rect rgb(240,240,240) + Note over C,P: 5. Weight exchange + Note over C: status "updating model" + C->>P: Payload { aggregationRound, communicationRound, weights } + P-->>C: Payload from each peer + Note over C: aggregator aggregates once full + C-->>T: aggregated weights + end + + opt participants drop below the minimum, at any point + S-->>C: WaitingForMoreParticipants + Note over C: status "not enough participants", block before sending weights + S-->>C: EnoughParticipants + Note over C: resume, re-emit the previous status + end +``` diff --git a/discojs/src/client/decentralized/decentralized_client.ts b/discojs/src/client/decentralized/decentralized_client.ts index 85b7b0beb..cb54c0de3 100644 --- a/discojs/src/client/decentralized/decentralized_client.ts +++ b/discojs/src/client/decentralized/decentralized_client.ts @@ -25,6 +25,8 @@ const debug = createDebug("discojs:client:decentralized"); * help of the network's server, yet only exchange payloads between each other. Communication * with the server is based off regular WebSockets, whereas peer-to-peer communication uses * WebRTC for Node.js. + * + * See decentralized README.md for schema of the event flow. */ export class DecentralizedClient extends Client<"decentralized"> { /** @@ -163,7 +165,8 @@ export class DecentralizedClient extends Client<"decentralized"> { } // Save the status in case participants leave and we switch to waiting for more participants // Once enough new participants join we can display the previous status again - this.saveAndEmit("connecting to peers"); + // We are done with our round and now wait for the peers to be done with theirs + this.saveAndEmit("waiting for peers to share weights"); // First we check if we are waiting for more participants before sending our weight update await this.waitForParticipantsIfNeeded(); // Create peer-to-peer connections with all peers for the round @@ -204,6 +207,8 @@ export class DecentralizedClient extends Client<"decentralized"> { this.server, MType.PeersForRound, ); + // every peer is ready to share weights, we can now connect to them + this.saveAndEmit("connecting to peers"); const peers = Set(receivedMessage.peers); diff --git a/discojs/src/client/federated/README.md b/discojs/src/client/federated/README.md new file mode 100644 index 000000000..6c7e36e03 --- /dev/null +++ b/discojs/src/client/federated/README.md @@ -0,0 +1,59 @@ +# Federated Event flow + +```mermaid +sequenceDiagram + autonumber + participant T as Trainer + participant C as FederatedClient + participant S as Server + participant O as Other clients + + rect rgb(240,240,240) + Note over T,S: 1. Connecting + T->>C: connect() + C->>S: WebSocket connect + ClientConnected + S-->>C: NewFederatedNodeInfo { id, payload, round, nbOfParticipants, waitForMoreParticipants } + C-->>T: base model with the latest global weights + end + + rect rgb(240,240,240) + Note over T,S: 2. Round begin + T->>C: onRoundBeginCommunication() + Note over C: status "local training" + T->>T: local training + end + + rect rgb(240,240,240) + Note over T,S: 3. Round end, sending the local update + T->>C: onRoundEndCommunication(weights) + Note over C: status "updating model" + C->>S: SendPayload { payload, round } + end + + rect rgb(240,240,240) + Note over C,O: 4. Server aggregation + O->>S: SendPayload from the other clients + Note over S: MeanAggregator waits for all
registered clients of the round + Note over S: aggregate, save as the latest global weights + end + + rect rgb(240,240,240) + Note over T,O: 5. Global update + S-->>C: ReceiveServerPayload { payload, round, nbOfParticipants } + S-->>O: ReceiveServerPayload + Note over C: aggregator.setRound(round) + C-->>T: global weights + end + + opt stale or invalid contribution + Note over S: contribution dropped, no aggregation + S-->>C: ReceiveServerPayload with the previous round's global weights + end + + opt participants drop below the minimum, at any point + S-->>C: WaitingForMoreParticipants + Note over C: status "not enough participants", block before sending weights + S-->>C: EnoughParticipants + Note over C: resume, re-emit the previous status + end +``` diff --git a/discojs/src/client/federated/federated_client.ts b/discojs/src/client/federated/federated_client.ts index d8812a622..4f613a7cc 100644 --- a/discojs/src/client/federated/federated_client.ts +++ b/discojs/src/client/federated/federated_client.ts @@ -21,6 +21,9 @@ const SERVER_NODE_ID = "federated-server-node-id"; /** * Client class that communicates with a centralized, federated server, when training * a specific task in the federated setting. + * + * See federated README.md for schema of the event flow. + * */ export class FederatedClient extends Client<"federated"> { /** diff --git a/discojs/src/training/types.ts b/discojs/src/training/types.ts index 87be9e6ad..52459d021 100644 --- a/discojs/src/training/types.ts +++ b/discojs/src/training/types.ts @@ -17,6 +17,7 @@ export type SummaryLogs = { export type RoundStatus = | "not enough participants" // Server notification to wait for more participants + | "waiting for peers to share weights" // for decentralized only, the other peers are still training their round | "updating model" // fetching/aggregating local updates into a global model | "local training" // Training the model locally - | "connecting to peers"; // for decentralized only, fetch the server's list of participating peers + | "connecting to peers"; // for decentralized only, establishing the peer-to-peer connections diff --git a/server/tests/e2e/decentralized.spec.ts b/server/tests/e2e/decentralized.spec.ts index 15e6a181e..584f1f20d 100644 --- a/server/tests/e2e/decentralized.spec.ts +++ b/server/tests/e2e/decentralized.spec.ts @@ -160,7 +160,11 @@ describe("end-to-end decentralized", { timeout: 50_000 }, () => { await reachConsensus(url, "secure", 3); }); - it("peers emit expected events", { timeout: 100_000 }, async () => { + /** The LUS COVID task, decentralized between at least two participants */ + async function lusCovidDecentralized(): Promise<{ + task: Task<"image", "decentralized">; + taskProvider: TaskProvider<"image", "decentralized">; + }> { const baseTask = await defaultTasks.lusCovid.getTask(); const task: Task<"image", "decentralized"> = { ...baseTask, @@ -172,10 +176,17 @@ describe("end-to-end decentralized", { timeout: 50_000 }, () => { minNbOfParticipants: 2, }, }; - const taskProvider = { - ...defaultTasks.lusCovid, - getTask: () => Promise.resolve(task), + return { + task, + taskProvider: { + ...defaultTasks.lusCovid, + getTask: () => Promise.resolve(task), + }, }; + } + + it("peers emit expected events", { timeout: 100_000 }, async () => { + const { task, taskProvider } = await lusCovidDecentralized(); const url = await startServer(defaultModels.LUSClassifier, taskProvider); const dataset = await datasets.loadLusCOVID(); @@ -187,11 +198,11 @@ describe("end-to-end decentralized", { timeout: 50_000 }, () => { * (without waiting for a server answer) * b) local training (the status remains "local training") * c) During onRoundEndCommunication - * 1. the peer notifies the server that they are ready to share weights - * set status to "connecting to peers" + * 1. the peer sets its status to "waiting for peers to share weights" + * and notifies the server that they are ready to share weights * 2. wait for the server to answer with the current round's peers list * this is where the nb of participants is updated - * 3. establish peer-to-peer connections + * 3. set status to "connecting to peers" and establish the connections * 4. set status to "updating model" and exchange weight updates * * Given this, it is important to note that calling disco.trainByRound().next() @@ -235,7 +246,9 @@ describe("end-to-end decentralized", { timeout: 50_000 }, () => { // Calling next() a 2nd time makes User 1 go to c) where the peer should // stay stuck awaiting until another participant joins const logUser1Round2Promise = generatorUser1.next(); - expect(await statusUser1.next()).equal("connecting to peers"); // tries to connect to peers + expect(await statusUser1.next()).equal( + "waiting for peers to share weights", + ); // ready to share expect(await statusUser1.next()).equal("not enough participants"); // but has to wait for more participants /* USER 2 JOINS */ @@ -264,7 +277,9 @@ describe("end-to-end decentralized", { timeout: 50_000 }, () => { // User 2 did a) and b) expect(await statusUser2.next()).equal("local training"); // User 1 is still in c) now waiting for user 2 to be ready to exchange weight updates - expect(await statusUser1.next()).equal("connecting to peers"); + expect(await statusUser1.next()).equal( + "waiting for peers to share weights", + ); /* ROUND 2 */ @@ -282,10 +297,14 @@ describe("end-to-end decentralized", { timeout: 50_000 }, () => { expect(await nbParticipantsUser2.next()).equal(2); expect(await nbParticipantsUser1.next()).equal(2); // User 1 and 2 did c), a) and b) + expect(await statusUser1.next()).equal("connecting to peers"); expect(await statusUser1.next()).equal("updating model"); // second to last expect(await statusUser1.next()).equal("local training"); - expect(await statusUser2.next()).equal("connecting to peers"); // back to connecting when user 1 joins + expect(await statusUser2.next()).equal( + "waiting for peers to share weights", + ); + expect(await statusUser2.next()).equal("connecting to peers"); expect(await statusUser2.next()).equal("updating model"); expect(await statusUser2.next()).equal("local training"); @@ -302,7 +321,9 @@ describe("end-to-end decentralized", { timeout: 50_000 }, () => { const logUser2Round3Promise = generatorUser2.next(); // await new Promise((res, _) => setTimeout(res, statusUpdateTime)) // Wait some time for the status to update // starts c) and waits for user 3 to join - expect(await statusUser2.next()).equal("connecting to peers"); + expect(await statusUser2.next()).equal( + "waiting for peers to share weights", + ); expect(await statusUser2.next()).equal("not enough participants"); /* USER 3 JOINS */ @@ -333,7 +354,9 @@ describe("end-to-end decentralized", { timeout: 50_000 }, () => { // User 3 did a) and b) expect(await statusUser3.next()).equal("local training"); // User 2 is still in c) waiting for user 3 to be ready to exchange waits - expect(await statusUser2.next()).equal("connecting to peers"); + expect(await statusUser2.next()).equal( + "waiting for peers to share weights", + ); /* ROUND 3 */ @@ -350,9 +373,13 @@ describe("end-to-end decentralized", { timeout: 50_000 }, () => { expect(await nbParticipantsUser2.next()).equal(2); // both user 2 and 3 did c), a) and are now in b) + expect(await statusUser2.next()).equal("connecting to peers"); expect(await statusUser2.next()).equal("updating model"); expect(await statusUser2.next()).equal("local training"); + expect(await statusUser3.next()).equal( + "waiting for peers to share weights", + ); expect(await statusUser3.next()).equal("connecting to peers"); expect(await statusUser3.next()).equal("updating model"); expect(await statusUser3.next()).equal("local training"); @@ -365,4 +392,64 @@ describe("end-to-end decentralized", { timeout: 50_000 }, () => { await discoUser3.close(); }); + + // regression test, peer used to display missing participants when + // it was not the case + it( + "peer sharing its weights doesn't report missing participants", + { timeout: 100_000 }, + async () => { + const { task, taskProvider } = await lusCovidDecentralized(); + const url = await startServer(defaultModels.LUSClassifier, taskProvider); + const dataset = await datasets.loadLusCOVID(); + + /** + * The timeline is: + * - User 1 joins the task by themselves and trains locally + * - User 2 joins while User 1 is still training + * - User 1 is done training and waits for User 2 to share its weights + * + * User 1 has to wait for User 2 to be ready but shouldn't be told that + * participants are missing: User 2 is here, only still training. + */ + + /* USER 1 JOINS */ + + const discoUser1 = new Disco(task, url, { preprocessOnce: true }); + const statusUser1 = new Queue(); + discoUser1.on("status", (status) => { + statusUser1.put(status); + }); + const generatorUser1 = discoUser1.trainByRound(dataset); + + await generatorUser1.next(); // a) and b) + expect(await statusUser1.next()).equal("local training"); + + /* USER 2 JOINS, WHILE USER 1 IS STILL TRAINING */ + + const discoUser2 = new Disco(task, url, { preprocessOnce: true }); + const generatorUser2 = discoUser2.trainByRound(dataset); + await generatorUser2.next(); // a) and b) + + // there are enough participants now, User 1 keeps on training + expect(await statusUser1.next()).equal("local training"); + + /* USER 1 IS DONE TRAINING */ + + const logUser1Round2 = generatorUser1.next(); // c) + expect(await statusUser1.next()).equal( + "waiting for peers to share weights", + ); + + /* USER 2 IS DONE TRAINING TOO */ + + await generatorUser2.next(); + await logUser1Round2; + expect(await statusUser1.next()).equal("connecting to peers"); + expect(await statusUser1.next()).equal("updating model"); + + await discoUser1.close(); + await discoUser2.close(); + }, + ); }); diff --git a/server/tests/e2e/federated.spec.ts b/server/tests/e2e/federated.spec.ts index f6d3e2f5e..e03686d80 100644 --- a/server/tests/e2e/federated.spec.ts +++ b/server/tests/e2e/federated.spec.ts @@ -73,39 +73,45 @@ describe("end-to-end federated", () => { return [disco.trainer.model.weights, lastEpoch]; } - it("three cifar10 users reach consensus", { timeout: 200_000 }, async () => { - const task = await defaultTasks.cifar10.getTask(); - const cifar10Task: Task<"image", "federated"> = { - ...task, - trainingInformation: { - ...task.trainingInformation, - scheme: "federated", - aggregationStrategy: "mean", - minNbOfParticipants: 2, - }, - }; - const cifar10TaskProvider = { - getTask: () => Promise.resolve(cifar10Task), - modelCard: defaultModels.CIFAR10Classifier, - }; - const url = await startServer( - defaultModels.CIFAR10Classifier, - cifar10TaskProvider, - ); - const dataset = await datasets.loadCifar10(); + it( + "three cifar10 users reach consensus", + { timeout: 200_000 }, + async () => { + const task = await defaultTasks.cifar10.getTask(); + const cifar10Task: Task<"image", "federated"> = { + ...task, + trainingInformation: { + ...task.trainingInformation, + privacy: undefined, + scheme: "federated", + aggregationStrategy: "mean", + minNbOfParticipants: 2, + validationSplit: 0.5, + }, + }; + const cifar10TaskProvider = { + getTask: () => Promise.resolve(cifar10Task), + modelCard: defaultModels.CIFAR10Classifier, + }; + const url = await startServer( + defaultModels.CIFAR10Classifier, + cifar10TaskProvider, + ); + const dataset = await datasets.loadCifar10(); - const [[m1, l1], [m2, l2], [m3, l3]] = await Promise.all([ - runUser(url, cifar10Task, dataset), - runUser(url, cifar10Task, dataset), - runUser(url, cifar10Task, dataset), - ]); + const [[m1, l1], [m2, l2], [m3, l3]] = await Promise.all([ + runUser(url, cifar10Task, dataset), + runUser(url, cifar10Task, dataset), + runUser(url, cifar10Task, dataset), + ]); - for (const lastEpoch of [l1, l2, l3]) { - expect(lastEpoch.training.accuracy).to.be.greaterThan(0.4); - expect(lastEpoch.validation?.accuracy).to.be.greaterThan(0.4); - } - assert.isTrue(m1.equals(m2) && m2.equals(m3)); - }); + for (const lastEpoch of [l1, l2, l3]) { + expect(lastEpoch.training.accuracy).to.be.greaterThan(0.4); + expect(lastEpoch.validation?.accuracy).to.be.greaterThan(0.4); + } + assert.isTrue(m1.equals(m2) && m2.equals(m3)); + }, + ); it("two titanic users reach consensus", { timeout: 50_000 }, async () => { const task = await defaultTasks.titanic.getTask(); diff --git a/webapp/cypress/e2e/datasetInput.cy.ts b/webapp/cypress/e2e/datasetInput.cy.ts index 50aeafbe6..43b2da26e 100644 --- a/webapp/cypress/e2e/datasetInput.cy.ts +++ b/webapp/cypress/e2e/datasetInput.cy.ts @@ -3,6 +3,55 @@ import { basicTask, setupServerWith } from "../support/e2e"; // TODO move to components testing // upstream doesn't yet allow that vuejs/test-utils#2468 +function droppedFolder(name: string, filenames: string[]): unknown { + const fileEntry = (filename: string) => ({ + isFile: true, + isDirectory: false, + name: filename, + file: (onSuccess: (file: File) => void) => + onSuccess( + new File([], filename, { + type: filename.endsWith(".png") ? "image/png" : "", + }), + ), + }); + + let read = false; + const directoryEntry = { + isFile: false, + isDirectory: true, + name, + createReader: () => ({ + readEntries: (onSuccess: (entries: unknown[]) => void) => { + onSuccess(read ? [] : filenames.map(fileEntry)); + read = true; + }, + }), + }; + + return { + items: [{ webkitGetAsEntry: () => directoryEntry }], + files: [], + dropEffect: "none", + }; +} + +function droppedFile(name: string, type: string): unknown { + const fileEntry = { + isFile: true, + isDirectory: false, + name, + file: (onSuccess: (file: File) => void) => + onSuccess(new File([], name, { type })), + }; + + return { + items: [{ webkitGetAsEntry: () => fileEntry }], + files: [], + dropEffect: "none", + }; +} + function goToDatasetInputStep() { cy.visit("/list"); cy.get(".driver-popover-close-btn").click(); @@ -50,6 +99,55 @@ describe("image dataset input by group", () => { cy.contains("Number of selected files: 3").should("exist"); }); + + it("allows to drop a folder of images", () => { + setupServerWith( + basicTask("image", { + LABEL_LIST: ["label"], + IMAGE_H: 100, + IMAGE_W: 100, + }), + ); + + goToDatasetInputStep(); + cy.get("button").contains("group").click(); + cy.contains("Drop images or a folder here"); + + cy.get('[data-testid="drop-image-area"]') + .first() + .trigger("drop", { + dataTransfer: droppedFolder("COVID+", [ + "first.png", + "second.png", + ".DS_Store", + ]), + }); + + cy.contains("Number of selected files: 2").should("exist"); + cy.contains("Ignored 1 file(s) that aren't images").should("exist"); + }); + + it("rejects a dropped file that isn't an image", () => { + setupServerWith( + basicTask("image", { + LABEL_LIST: ["label"], + IMAGE_H: 100, + IMAGE_W: 100, + }), + ); + + goToDatasetInputStep(); + cy.get("button").contains("group").click(); + + cy.get('[data-testid="drop-image-area"]') + .first() + .trigger("drop", { + dataTransfer: droppedFile("data.xlsx", "application/vnd.ms-excel"), + }); + + cy.contains("Didn't find any images in what you dropped").should("exist"); + cy.contains("Number of selected files").should("not.exist"); + }); }); describe("image dataset input by csv", () => { diff --git a/webapp/cypress/e2e/notFound.cy.ts b/webapp/cypress/e2e/notFound.cy.ts new file mode 100644 index 000000000..7b41ce6e3 --- /dev/null +++ b/webapp/cypress/e2e/notFound.cy.ts @@ -0,0 +1,41 @@ +import { defaultTasks } from "@epfml/discojs"; + +import { setupServerWith } from "../support/e2e.ts"; + +describe("not-found page", () => { + it("is shown for an unknown task", () => { + setupServerWith(defaultTasks.titanic); + + cy.visit("/not-a-task"); + cy.url().should("eq", `${Cypress.config().baseUrl}not-found`); + cy.contains("404"); + }); + + it("can be navigated away from", () => { + setupServerWith(defaultTasks.titanic); + + cy.visit("/not-a-task"); + cy.contains("404"); + + // the component of the unknown task is kept alive, it shouldn't redirect anymore + cy.get('aside a[href="/list"]').click(); + cy.url().should("eq", `${Cypress.config().baseUrl}list`); + }); + + it("is not covered by the tutorial when leaving it", () => { + setupServerWith(defaultTasks.titanic); + + cy.visit("/not-a-task"); + cy.contains("404"); + + // the tutorial starts on the task list and overlays the whole page + cy.get('aside a[href="/list"]').click(); + cy.get(".driver-popover"); + + // going back leaves no overlay swallowing the clicks + cy.go("back"); + cy.get(".driver-popover").should("not.exist"); + cy.get('aside a[href="/create"]').click(); + cy.url().should("eq", `${Cypress.config().baseUrl}create`); + }); +}); diff --git a/webapp/cypress/e2e/tutorial.cy.ts b/webapp/cypress/e2e/tutorial.cy.ts new file mode 100644 index 000000000..751ee3658 --- /dev/null +++ b/webapp/cypress/e2e/tutorial.cy.ts @@ -0,0 +1,147 @@ +import { defaultTasks } from "@epfml/discojs"; + +import { setupServerWith } from "../support/e2e.ts"; + +// The element highlighted by each step of the tutorial, in order. +// `undefined` for the steps that only display a centered popover. +const STEPS = [ + "#tuto-help-bttn", + undefined, + "#llm_task", + "#tuto-create-bttn", + "#tuto-evaluate-bttn", + "#lus_covid", + undefined, + "#tuto-training-bar", + ".tuto-data-desc", + ".tuto-example-data", + "#tuto-group-bttn", + ".group-data-field", + ".tuto-train-dash", + "#train-collab-bttn", + "#train-locally-bttn", + undefined, + "#tuto-evaluate-bttn", + "#tuto-slack-link", +]; + +function expectStep(index: number): void { + cy.get(".driver-popover-progress-text").should( + "have.text", + `Step ${index + 1} of ${STEPS.length}`, + ); + const selector = STEPS[index]; + if (selector === undefined) return; + cy.get(selector) + .should("have.class", "driver-active-element") + // the highlighted element has to be rendered: the elements of another + // training step are in the DOM but hidden, hence have no dimension + .and(($element) => { + expect( + $element[0].getBoundingClientRect().height, + `${selector} is displayed`, + ).to.be.greaterThan(0); + }); +} + +describe("tutorial", () => { + it("is shown on the first visit only", () => { + setupServerWith(defaultTasks.titanic); + + cy.visit("/list"); + cy.get(".driver-popover-close-btn").click(); + + // having been shown is persisted, it shouldn't show up again on a new load + cy.visit("/list"); + cy.contains("button", "participate"); // wait for the page to be loaded + cy.get(".driver-popover").should("not.exist"); + }); + + it("can still be started from the sidebar", () => { + setupServerWith(defaultTasks.titanic); + + cy.visit("/list"); + cy.get(".driver-popover-close-btn").click(); + + cy.get("#tuto-help-bttn").click(); + cy.get(".driver-popover"); + + // the skipped first step isn't reachable by going back + cy.get(".driver-popover-prev-btn").should("be.disabled"); + }); + + it("shows a single popover when started while navigating to the task list", () => { + setupServerWith(defaultTasks.titanic); + + // starting from another page navigates to the task list, whose mounting + // shouldn't start the tutorial a second time + cy.visit("/"); + cy.get("#tuto-help-bttn").click(); + + cy.contains(".driver-popover-title", "Welcome to DISCO!"); + cy.get(".driver-popover").should("have.length", 1); + }); + + it("only closes when clicking on the cross", () => { + setupServerWith(defaultTasks.titanic); + + cy.visit("/list"); + cy.get(".driver-popover"); + + // clicking outside of the popover is most likely a misclick, it shouldn't close + cy.get(".driver-overlay").click({ force: true }); + cy.get(".driver-popover"); + + cy.get(".driver-popover-close-btn").click(); + cy.get(".driver-popover").should("not.exist"); + }); + + it("displays the current step and the total number of steps", () => { + setupServerWith(defaultTasks.titanic); + + cy.visit("/list"); + cy.get(".driver-popover-progress-text").should("contain", "Step 1 of"); + cy.get(".driver-popover-next-btn").click(); + cy.get(".driver-popover-progress-text").should("contain", "Step 2 of"); + + cy.get(".driver-popover-close-btn").click(); + + // the first step is skipped when starting from the sidebar, + // it shouldn't be counted + cy.get("#tuto-help-bttn").click(); + cy.contains(".driver-popover-title", "Welcome to DISCO!"); + cy.get(".driver-popover-progress-text").should("contain", "Step 1 of"); + }); + it("can be navigated back and forth", () => { + setupServerWith(defaultTasks.lusCovid, defaultTasks.wikitext); + + cy.visit("/list"); + + // "previous" is disabled on the first step + cy.get(".driver-popover-prev-btn").should("be.disabled"); + + // go through the whole tutorial + for (let index = 0; index < STEPS.length; index++) { + expectStep(index); + if (index < STEPS.length - 1) cy.get(".driver-popover-next-btn").click(); + } + cy.url().should("match", /\/lus_covid$/); + + // go back to the beginning + for (let index = STEPS.length - 1; index >= 0; index--) { + expectStep(index); + if (index > 0) cy.get(".driver-popover-prev-btn").click(); + } + cy.url().should("match", /\/list$/); + + // and go the the end again + for (let index = 0; index < STEPS.length; index++) { + expectStep(index); + cy.get(".driver-popover-next-btn").click(); + } + + // the last step closes the tutorial and goes back to the task list + cy.get(".driver-popover").should("not.exist"); + cy.url().should("match", /\/list$/); + }); +}); diff --git a/webapp/src/assets/css/tailwind.css b/webapp/src/assets/css/tailwind.css index e66dc68c1..a578db524 100644 --- a/webapp/src/assets/css/tailwind.css +++ b/webapp/src/assets/css/tailwind.css @@ -25,4 +25,9 @@ --color-heading-light: #334155; --color-heading-dark: #fff; + + /* width of a single content card, the page's content column */ + --container-card: 700px; + /* two cards side by side, separated by `cards-gap` at md and above */ + --container-cards-2: calc(2 * 700px + 2rem); } diff --git a/webapp/src/components/dataset_input/DataDescription.vue b/webapp/src/components/dataset_input/DataDescription.vue index 0382548a6..af41e8d73 100644 --- a/webapp/src/components/dataset_input/DataDescription.vue +++ b/webapp/src/components/dataset_input/DataDescription.vue @@ -1,5 +1,5 @@