From 1d2f9f5b49183ac17c2fc6424f26f9a628cf2112 Mon Sep 17 00:00:00 2001 From: Xavier Rutayisire Date: Tue, 19 Dec 2023 20:51:43 +0100 Subject: [PATCH] test(playwright): Convert Cypress tests to Playwright - 2 --- .github/actions/restore-cache/action.yml | 8 +- .github/workflows/ci.yml | 56 ++++- cypress/e2e/slices/00-create.cy.js | 2 +- cypress/e2e/updates/sidebar.cy.js | 72 ------ cypress/e2e/updates/simulator-tooltip.cy.js | 2 +- cypress/e2e/updates/video-tooltip.cy.js | 75 ------ .../scenario_slice_association.cy.js | 46 ---- .../e2e/user-flows/transactional-push.cy.js | 140 ----------- .../components/Navigation/ChangesListItem.tsx | 2 +- .../SliceBuilder/SimulatorButton/index.tsx | 2 +- .../HoverCard/HoverCard.stories.tsx | 4 +- playwright/.env.local.example | 5 - playwright/.gitignore | 1 - playwright/README.md | 93 ++++++-- playwright/fixtures/index.ts | 222 ++++++------------ playwright/mocks/emptyLibraries.ts | 13 - playwright/mocks/generateLibraries.ts | 86 +++++++ playwright/mocks/generateTypes.ts | 48 ++++ playwright/mocks/index.ts | 4 +- playwright/mocks/simpleCustomType.ts | 15 -- playwright/package.json | 9 +- playwright/pages/ChangesPage.ts | 43 +++- playwright/pages/SliceMachinePage.ts | 6 +- playwright/pages/components/Menu.ts | 16 +- playwright/pages/components/ReviewDialog.ts | 49 ++++ playwright/playwright.config.ts | 31 ++- playwright/tests/changes/changes.spec.ts | 183 +++++++++++++-- playwright/tests/common/environment.spec.ts | 84 +++---- playwright/tests/common/navigation.spec.ts | 63 ----- playwright/tests/common/reviewForm.spec.ts | 158 +++++++++++++ playwright/tests/common/sideNav.spec.ts | 173 ++++++++++++++ playwright/utils/mockManagerProcedures.ts | 60 +++-- yarn.lock | 2 +- 33 files changed, 1066 insertions(+), 707 deletions(-) delete mode 100644 cypress/e2e/updates/sidebar.cy.js delete mode 100644 cypress/e2e/updates/video-tooltip.cy.js delete mode 100644 cypress/e2e/user-flows/transactional-push.cy.js delete mode 100644 playwright/.env.local.example delete mode 100644 playwright/mocks/emptyLibraries.ts create mode 100644 playwright/mocks/generateLibraries.ts create mode 100644 playwright/mocks/generateTypes.ts delete mode 100644 playwright/mocks/simpleCustomType.ts create mode 100644 playwright/pages/components/ReviewDialog.ts delete mode 100644 playwright/tests/common/navigation.spec.ts create mode 100644 playwright/tests/common/reviewForm.spec.ts create mode 100644 playwright/tests/common/sideNav.spec.ts diff --git a/.github/actions/restore-cache/action.yml b/.github/actions/restore-cache/action.yml index 90c32680ce..4062a753a1 100644 --- a/.github/actions/restore-cache/action.yml +++ b/.github/actions/restore-cache/action.yml @@ -11,7 +11,6 @@ runs: node_modules */*/node_modules ~/.cache/Cypress - ~/.cache/ms-playwright key: ${{ runner.os }}-${{ hashFiles('**/package-lock.json', '**/yarn.lock') }}-dependencies - name: Cache artifacts id: cache-artifacts @@ -21,3 +20,10 @@ runs: packages/*/dist packages/core/build key: ${{ runner.os }}-${{ github.run_id }}-artifacts + - name: Cache playwright binaries + id: cache-playwright-binaries + uses: actions/cache@v3 + with: + path: | + ~/.cache/ms-playwright + key: ${{ runner.os }}-${{ hashFiles('playwright/package.json') }}-playwright-binaries diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e9e07148a4..e4a38d0ae7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -151,7 +151,11 @@ jobs: e2e: needs: prepare - runs-on: [ubuntu-latest] + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + shard: [1/6, 2/6, 3/6, 4/6, 5/6, 6/6] steps: - name: Checkout uses: actions/checkout@v3 @@ -165,20 +169,54 @@ jobs: - name: Set up Playwright browsers run: yarn test:e2e:install + - name: Set up Slice Machine project + run: | + yarn workspaces foreach --include '{@slicemachine/adapter-next,@slicemachine/manager,@slicemachine/plugin-kit,start-slicemachine}' --parallel --topological-dev run build + yarn workspace slice-machine-ui pack --out ../../e2e-projects/next/sm.tgz + yarn workspace cimsirp add -D ./sm.tgz + - name: Running E2E tests - run: yarn test:e2e + run: yarn test:e2e --shard ${{ matrix.shard }} env: - PRISMIC_URL: https://wroom.io - WROOM_EMAIL: ${{ secrets.EMAIL }} - WROOM_PASSWORD: ${{ secrets.PASSWORD }} + DEBUG: pw:test + + - name: Upload blob report to GitHub Actions Artifacts + uses: actions/upload-artifact@v3 + if: always() + with: + name: e2e-all-blob-reports + path: playwright/blob-report + retention-days: 1 + + e2e-merge-reports: + needs: e2e + runs-on: ubuntu-latest + if: always() + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Restore cache + uses: ./.github/actions/restore-cache + + - name: Set up Node + uses: ./.github/actions/setup-node + + - name: Download blob reports from GitHub Actions Artifacts + uses: actions/download-artifact@v3 + with: + name: e2e-all-blob-reports + path: playwright/e2e-all-blob-reports + + - name: Merge into HTML Report + run: yarn test:e2e:merge-reports --reporter html ./e2e-all-blob-reports - name: Upload HTML report uses: actions/upload-artifact@v3 - if: failure() with: - name: Playwright HTML report - path: playwright/playwright-report/ - retention-days: 30 + name: html-report--attempt-${{ github.run_attempt }} + path: playwright/playwright-report + retention-days: 14 e2e-cypress: needs: prepare diff --git a/cypress/e2e/slices/00-create.cy.js b/cypress/e2e/slices/00-create.cy.js index e0fb39ebba..f6ee653d86 100644 --- a/cypress/e2e/slices/00-create.cy.js +++ b/cypress/e2e/slices/00-create.cy.js @@ -10,7 +10,7 @@ const editedSliceName = "EditedSliceName"; const sliceId = "test_slice"; // generated automatically from the slice name const lib = ".--slices"; -describe("Create Slices", () => { +describe.skip("Create Slices", () => { beforeEach(() => { cy.setSliceMachineUserContext({}); cy.clearProject(); diff --git a/cypress/e2e/updates/sidebar.cy.js b/cypress/e2e/updates/sidebar.cy.js deleted file mode 100644 index 146b1d9671..0000000000 --- a/cypress/e2e/updates/sidebar.cy.js +++ /dev/null @@ -1,72 +0,0 @@ -// TODO: Change this to an integration test. -describe.skip("update notification", () => { - function mockChangelogCall(releaseNote) { - cy.intercept("GET", "/api/changelog", { - statusCode: 200, - body: { - currentVersion: "0.5.0", - updateAvailable: true, - latestNonBreakingVersion: "1.2.3", - versions: [ - { - versionNumber: "1000.0.0", - status: "PATCH", - releaseNote: null, - }, - ], - }, - }); - } - - it("updates available and user has not seen the notification", () => { - cy.setSliceMachineUserContext({}); - mockChangelogCall(); - - cy.visit("/"); - cy.get("[data-testid=the-red-dot]").should("exist"); - cy.contains("Learn more").click(); - cy.location("pathname", { timeout: 1000 }).should("eq", "/changelog"); - - cy.visit("/"); - cy.contains("Learn more").should("exist"); - cy.get("[data-testid=the-red-dot]").should("not.exist"); - - cy.getLocalStorage("persist:root").then((str) => { - const obj = JSON.parse(str); - const userContext = JSON.parse(obj.userContext); - - expect(userContext.updatesViewed).to.deep.equal({ - latest: "1000.0.0", - latestNonBreaking: "1.2.3", - }); - }); - }); - - it("updates available and user has seen the notification", () => { - cy.setSliceMachineUserContext({ - updatesViewed: { - latest: "1000.0.0", - latestNonBreaking: "1.2.3", - }, - }); - mockChangelogCall(); - - cy.visit("/"); - cy.contains("Learn more", { timeout: 60000 }).should("exist"); - cy.get("[data-testid=the-red-dot]").should("not.exist"); - }); - - it("user has seen the updates but an even newer on is available", () => { - cy.setSliceMachineUserContext({ - updatesViewed: { - latest: "999.0.0", - latestNonBreaking: "1.2.3", - }, - }); - mockChangelogCall(); - - cy.visit("/"); - cy.contains("Learn more").should("exist"); - cy.get("[data-testid=the-red-dot]").should("exist"); - }); -}); diff --git a/cypress/e2e/updates/simulator-tooltip.cy.js b/cypress/e2e/updates/simulator-tooltip.cy.js index e564b48f8a..0fdcffb874 100644 --- a/cypress/e2e/updates/simulator-tooltip.cy.js +++ b/cypress/e2e/updates/simulator-tooltip.cy.js @@ -20,7 +20,7 @@ describe("simulator tooltip", () => { cy.contains("Simulate your slices").should("exist"); - cy.contains("Got It").click(); + cy.contains("Got it").click(); cy.contains("Simulate your slices").should("not.exist"); diff --git a/cypress/e2e/updates/video-tooltip.cy.js b/cypress/e2e/updates/video-tooltip.cy.js deleted file mode 100644 index 21aa5bd213..0000000000 --- a/cypress/e2e/updates/video-tooltip.cy.js +++ /dev/null @@ -1,75 +0,0 @@ -// TODO: DT-1435 - Handle tests when updating video item -describe.skip("video tooltip", () => { - it("should display the tooltip when 'userContext.hasSeenTutorialsToolTip' is falsy and set to true when user clicks the close button", () => { - cy.setSliceMachineUserContext({ hasSeenTutorialsToolTip: false }); - - cy.visit("/"); - - cy.get("[role=tooltip]", { timeout: 6_000 }).should("have.class", "show"); - - cy.get("[data-testid=video-tooltip-close-button]").click(); - - cy.get("[data-testid=video-tooltip]").should("not.exist"); - - cy.getSliceMachineUserContext().then((data) => { - expect(data.hasSeenTutorialsToolTip).equal( - true, - "userContext.hasSeenTutorialsToolTip should set in local storage", - ); - }); - }); - - it("should no display when hasSeenTutorialsToolTip is truthy", () => { - cy.setSliceMachineUserContext({}); - - cy.get("[role=tooltip]", { timeout: 6_000 }).should("not.exist"); - }); - - it("should close the tooltip when the user clicks the videos button", () => { - cy.setSliceMachineUserContext({ hasSeenTutorialsToolTip: false }); - - cy.visit("/"); - - cy.get("[role=tooltip]", { timeout: 6_000 }).should("have.class", "show"); - - cy.contains("Tutorial") - .should("have.attr", "target", "_blank") - .should( - "have.attr", - "href", - "https://youtube.com/playlist?list=PLUVZjQltoA3wnaQudcqQ3qdZNZ6hyfyhH", - ) - .click(); - - cy.getSliceMachineUserContext().should((data) => { - expect(data.hasSeenTutorialsToolTip).equal( - true, - "userContext.hasSeenTutorialsToolTip should set in local storage", - ); - }); - }); - - it("should disappear when the user hovers over the video toolbar", () => { - cy.setSliceMachineUserContext({ hasSeenTutorialsToolTip: false }); - - cy.visit("/"); - - cy.get("[role=tooltip]", { timeout: 6_000 }).should("have.class", "show"); - - cy.contains("Tutorial") - .trigger("mouseenter") - .trigger("mouseleave") - .trigger("mouseover") - .trigger("mousemove") - .trigger("mouseout"); - - cy.get("[data-testid=video-tooltip]").should("not.exist"); - - cy.getSliceMachineUserContext().should((data) => { - expect(data.hasSeenTutorialsToolTip).equal( - true, - "userContext.hasSeenTutorialsToolTip should set in local storage", - ); - }); - }); -}); diff --git a/cypress/e2e/user-flows/scenario_slice_association.cy.js b/cypress/e2e/user-flows/scenario_slice_association.cy.js index db35185886..d1117cc88a 100644 --- a/cypress/e2e/user-flows/scenario_slice_association.cy.js +++ b/cypress/e2e/user-flows/scenario_slice_association.cy.js @@ -78,50 +78,4 @@ describe.skip("I am an existing SM user (Next) and I want to associate a Slice t cy.contains("Repeatable Rich Text Field"); cy.contains("Repeatable Key Text Field"); }); - - it("Push the newly created custom type and slice", () => { - changesPage.goTo().pushChanges().isUpToDate(); - }); - - it.skip("Add the Slice to the Custom Type", () => { - cy.visit(`/custom-types/${customTypeId}`); - - cy.get("[data-cy=update-slices]").click(); - cy.get(`[data-cy=shared-slice-card-${sliceId}]`).click(); - cy.get("[data-cy=update-slices-modal]").submit(); - - customTypeBuilder.save(); - - cy.reload(); - cy.contains(sliceName); - }); - - it.skip("Push the custom type with the Slice associated", () => { - changesPage.goTo().pushChanges().isUpToDate(); - }); - - it.skip("Displays and fill the satisfaction survey and the survey never reappears after", () => { - const lastSyncChange = Date.now() - 1000 * 60 * 60 * 2; - - // Setting the context to display the survey - cy.setSliceMachineUserContext({ - hasSendAReview: false, - lastSyncChange, - }); - - // Visit a page - cy.visit("/"); - - cy.get("#review-form"); - cy.get("[data-cy=review-form-score-3]").click(); - cy.get("[data-cy=review-form-comment]").type( - "Cypress test - testing the comment of the survey", - ); - cy.get("#review-form").submit(); - - cy.reload(); - cy.waitUntil(() => cy.get("[data-cy=create-ct]")); - - cy.get("#review-form").should("not.exist"); - }); }); diff --git a/cypress/e2e/user-flows/transactional-push.cy.js b/cypress/e2e/user-flows/transactional-push.cy.js deleted file mode 100644 index f0cf62ef55..0000000000 --- a/cypress/e2e/user-flows/transactional-push.cy.js +++ /dev/null @@ -1,140 +0,0 @@ -import { sliceBuilder } from "../../pages/slices/sliceBuilder"; -import { menu } from "../../pages/menu"; -import { customTypeBuilder } from "../../pages/customTypes/customTypeBuilder"; -import { slicesList } from "../../pages/slices/slicesList"; -import { changesPage } from "../../pages/changes/changesPage"; -import { - hardDeleteDocumentsDrawer, - referencesErrorDrawer, - softDeleteDocumentsDrawer, -} from "../../pages/changes/drawers"; - -// TODO: enable when transactional push requests responses can be mocked -describe.skip("I am an existing SM user and I want to push local changes", () => { - const random = Date.now(); - - const slice = { - id: `test_push${random}`, - name: `TestPush${random}`, - library: ".--slices", - }; - - const customType = { - id: `push_ct_${random}`, - name: `Push CT ${random}`, - }; - - beforeEach("Start from the Slice page", () => { - cy.clearProject(); - cy.setSliceMachineUserContext({}); - }); - - it("Creates, updates and deletes slices and custom types", () => { - cy.createSlice(slice.library, slice.id, slice.name); - cy.createCustomType(customType.id, customType.name); - - menu.navigateTo("Changes"); - - changesPage.pushChanges().isUpToDate(); - - customTypeBuilder.goTo(customType.id).addTab("Foo").save(); - - sliceBuilder.goTo(slice.library, slice.name).addVariation("foo").save(); - - menu.navigateTo("Changes"); - - changesPage.pushChanges(2); - - cy.deleteCustomType(customType.id); - - menu.navigateTo("Slices"); - - slicesList.deleteSlice(slice.name); - - menu.navigateTo("Changes"); - - const customTypesWithDocuments = [ - { - id: customType.id, - numberOfDocuments: 2000, - url: "url", - }, - ]; - - changesPage.mockPushLimit("HARD", customTypesWithDocuments); - - changesPage.pushChanges(); - - hardDeleteDocumentsDrawer.title.should("be.visible"); - hardDeleteDocumentsDrawer - .getAssociatedDocuments(customType.name) - .contains(customTypesWithDocuments[0].numberOfDocuments); - - changesPage.mockPushLimit("SOFT", customTypesWithDocuments); - - hardDeleteDocumentsDrawer.pushButton.click(); - - softDeleteDocumentsDrawer.title.should("be.visible"); - softDeleteDocumentsDrawer - .getAssociatedDocuments(customType.name) - .contains(customTypesWithDocuments[0].numberOfDocuments); - - softDeleteDocumentsDrawer.pushButton.should("be.disabled"); - - changesPage.unMockPushRequest(); - - softDeleteDocumentsDrawer.confirmDelete(); - - changesPage.isUpToDate(); - }); - - it("shows a toaster on error", () => { - cy.createCustomType(customType.id, customType.name); - - cy.visit("/changes"); - - changesPage.mockPushError(500).pushChanges(); - - cy.contains( - "Something went wrong when pushing your changes. Check your terminal logs.", - ); - - cy.clearProject(); - }); - - it("show's the login modal on auth error", () => { - cy.createCustomType(customType.id, customType.name); - - cy.visit("/changes"); - - changesPage.mockPushError(403).pushChanges(); - - cy.get("[aria-modal]").contains("You're not connected"); - cy.get("[aria-modal]").get("[aria-label='Close']").click(); - - cy.clearProject(); - }); - - it("show removed slice references", async () => { - cy.createSlice(slice.library, slice.id, slice.name); - cy.createCustomType(customType.id, customType.name); - - customTypeBuilder.goTo(customType.id); - - await customTypeBuilder.addSliceToSliceZone(slice.id).save(); - - cy.clearSlices(); - - menu.navigateTo("Changes"); - changesPage.pushChanges(); - - referencesErrorDrawer.title.should("be.visible"); - referencesErrorDrawer.getCustomTypeReferencesCard(customType.name); - - customTypeBuilder.goTo(customType.id).save(); - - menu.navigateTo("Changes"); - - changesPage.pushChanges().isUpToDate(); - }); -}); diff --git a/packages/slice-machine/components/Navigation/ChangesListItem.tsx b/packages/slice-machine/components/Navigation/ChangesListItem.tsx index 166f238b13..e285dc1292 100644 --- a/packages/slice-machine/components/Navigation/ChangesListItem.tsx +++ b/packages/slice-machine/components/Navigation/ChangesListItem.tsx @@ -58,7 +58,7 @@ export const ChangesListItem: FC = () => { When you click Save, your changes are saved locally. Then, you can push your models to Prismic from the Changes page. - Got It + Got it ); }; diff --git a/packages/slice-machine/lib/builders/SliceBuilder/SimulatorButton/index.tsx b/packages/slice-machine/lib/builders/SliceBuilder/SimulatorButton/index.tsx index bc965829d1..e1751dade2 100644 --- a/packages/slice-machine/lib/builders/SliceBuilder/SimulatorButton/index.tsx +++ b/packages/slice-machine/lib/builders/SliceBuilder/SimulatorButton/index.tsx @@ -65,7 +65,7 @@ const SimulatorOnboardingTooltip: React.FC< Minimize context-switching by previewing your Slice components in the simulator. - Got It + Got it ); }; diff --git a/packages/slice-machine/src/components/HoverCard/HoverCard.stories.tsx b/packages/slice-machine/src/components/HoverCard/HoverCard.stories.tsx index a2e5faa044..8d3d4ab4ad 100644 --- a/packages/slice-machine/src/components/HoverCard/HoverCard.stories.tsx +++ b/packages/slice-machine/src/components/HoverCard/HoverCard.stories.tsx @@ -37,7 +37,7 @@ export const Image = { Lorem ipsum dolor sit amet consectetur. Aenean purus aliquam vel eget vitae etiam - Got It + Got it ), }, @@ -62,7 +62,7 @@ export const Video = { Lorem ipsum dolor sit amet consectetur. Aenean purus aliquam vel eget vitae etiam - Got It + Got it ), }, diff --git a/playwright/.env.local.example b/playwright/.env.local.example deleted file mode 100644 index d7e8b75711..0000000000 --- a/playwright/.env.local.example +++ /dev/null @@ -1,5 +0,0 @@ -WROOM_EMAIL=email@example.com -WROOM_PASSWORD=my_password - -PRISMIC_EMAIL=email@example.com -PRISMIC_PASSWORD=my_password diff --git a/playwright/.gitignore b/playwright/.gitignore index 3b98d49bbe..53808aaf7d 100644 --- a/playwright/.gitignore +++ b/playwright/.gitignore @@ -1,3 +1,2 @@ -.env.local /test-results/ /playwright-report/ diff --git a/playwright/README.md b/playwright/README.md index 877a76329e..658d97e4f1 100644 --- a/playwright/README.md +++ b/playwright/README.md @@ -10,13 +10,6 @@ _Install browsers and OS dependencies for Playwright._ yarn test:e2e:install ``` -- Create a `.env.local` file - -Copy-paste `playwright/.env.local.example` in `playwright/.env.local` and update `EMAIL` and `PASSWORD` values. - -Having both Wroom and Prismic values will help you run Slice Machine in dev or prod mode without having to take care of the correct email or password. -Wroom or Prismic values will be used depending on the Prismic URL. - - Install the VS Code extension (optional) Playwright Test extension was created specifically to accommodate the needs of e2e testing. [Install Playwright Test for VSCode by reading this page](https://playwright.dev/docs/getting-started-vscode). It will help you to debug a problem in tests if needed. @@ -70,19 +63,17 @@ yarn test:e2e:report To open a downloaded CI test report from anywhere in your computer: -```bash -npx playwright show-report name-of-my-extracted-playwright-report -``` +- Copy-paste the content of the download report in a `playwright-report` folder within `playwright` folder +- Execute the same command as above ## Creating tests ### `test.run()` Use `test.run()` to create a test. `run` function take an optional object parameter `options` that let you configure how you want to run the test. -You can configure if you want a logged in test and also an onboarded test. -Default is not logged in and onboarded. +You can configure if you want an onboarded test. Default is onboarded. -Example for a logged in user not onboarded: +Example for user not onboarded: ```ts test.run({ loggedIn: true, onboarded: false })( @@ -93,7 +84,33 @@ test.run({ loggedIn: true, onboarded: false })( ); ``` -Warning: Only use `loggedIn: true` when it's necessary for your test, it increases local test time (not in CI) by some seconds (≃ 3 secs). +You can also override default storage values: + +Example (redux): +```ts +test.run({ + onboarded: false, + reduxStorage: { + lastSyncChange: new Date().getTime(), + }, +})("I can ...", async ({ sliceBuilderPage, slicesListPage }) => { + // Test content +}); +``` + +Example (new way): +```ts +test.run({ + onboarded: false, + storage: { + isInAppGuideOpen: true, + }, +})("I can ...", + async ({ sliceBuilderPage, slicesListPage }) => { + // Test content + }, +); +``` ### Mocking with `mockManagerProcedures` @@ -112,7 +129,7 @@ await mockManagerProcedures({ path: "getState", data: (data) => ({ ...data, - libraries: emptyLibraries, + libraries: generateLibraries({ nbSlices: 0 }), customTypes: [], remoteCustomTypes: [], remoteSlices: [], @@ -126,9 +143,10 @@ await mockManagerProcedures({ }); ``` -Warning: Only mock when it's necessary because the state of Slice Machine or the remote repository can change. -We want to ensure test can be launched on any state of Slice Machine and any state of repository. Mocking will help you do that. -In theory, we want to avoid mocking while doing e2e tests. Smoke tests don't have any mocking but standalone tests can when it's necessary. It improves the DX and reduce the necessary setup that we can have for Smoke tests. +> [!WARNING] +> Only mock when it's necessary because the state of Slice Machine or the remote repository can change. +> We want to ensure test can be launched on any state of Slice Machine and any state of repository. Mocking will help you do that. +> In theory, we want to avoid mocking while doing e2e tests. Smoke tests don't have any mocking but standalone tests can when it's necessary. It improves the DX and reduce the necessary setup that we can have for Smoke tests. ## Best practices @@ -143,7 +161,8 @@ This approach has several benefits: - Readability: Tests become more readable and easier to understand. - Reusability: You can reuse code across different test cases. -**Warning**: Never use a locator directly in a test file (getBy...). Always use the Page Object Model design pattern for that. +> [!WARNING] +> Never use a locator directly in a test file (getBy...). Always use the Page Object Model design pattern for that. ### Always try to do an exact matching with locators @@ -222,6 +241,42 @@ test.run()("I can create a slice", async () => { }); ``` +### Always check that at least one locator is visible before checking if another locator is not visible + +Directly checking that a locator is not visible is not correct if the page is currently loading. The loading blank page will not contain your locator and it will always pass. + +Example (bad): + +```ts +test.run()( + "I cannot see the updates available warning", + async ({ pageTypesTablePage }) => { + await pageTypesTablePage.goto(); + await expect( + pageTypesTablePage.menu.updatesAvailableTitle, + ).not.toBeVisible(); + }, +); +``` + +Example (good): + +```ts +test.run()( + "I cannot see the updates available warning", + async ({ pageTypesTablePage }) => { + await pageTypesTablePage.goto(); + await expect(pageTypesTablePage.menu.appVersion).toBeVisible(); + await expect( + pageTypesTablePage.menu.updatesAvailableTitle, + ).not.toBeVisible(); + }, +); +``` + +> [!NOTE] +> Include the check within the goto function so you don't need to do it manually every time. + ### Write your own best practice for the team here... ## Useful links diff --git a/playwright/fixtures/index.ts b/playwright/fixtures/index.ts index a98bcc9846..f1f372c28f 100644 --- a/playwright/fixtures/index.ts +++ b/playwright/fixtures/index.ts @@ -1,8 +1,6 @@ -import * as dotenv from "dotenv"; import * as fs from "fs/promises"; import * as os from "os"; import * as path from "path"; -import { decode } from "@msgpack/msgpack"; import { test as baseTest, expect } from "@playwright/test"; import { PageTypesTablePage } from "../pages/PageTypesTablePage"; @@ -17,8 +15,6 @@ import { SliceMachinePage } from "../pages/SliceMachinePage"; import { generateRandomId } from "../utils/generateRandomId"; import config from "../playwright.config"; -dotenv.config({ path: `.env.local` }); - export type DefaultFixtures = { /** * Pages @@ -47,9 +43,13 @@ export type DefaultFixtures = { * Default test fixture */ export const defaultTest = ( - options: { loggedIn?: boolean; onboarded?: boolean } = {}, + options: { + onboarded?: boolean; + reduxStorage?: Record; + storage?: Record; + } = {}, ) => { - const { loggedIn = false, onboarded = true } = options; + const { onboarded = true, reduxStorage = {}, storage = {} } = options; return baseTest.extend({ /** @@ -149,61 +149,84 @@ export const defaultTest = ( * Page */ page: async ({ browser }, use) => { - // Onboard user in Local Storage by default - let context = await browser.newContext({ - storageState: { - cookies: [], - origins: [ + // Create redux storage state + const userContext = onboarded + ? { + userReview: { + onboarding: false, + advancedRepository: true, + }, + updatesViewed: { + latest: null, + latestNonBreaking: null, + }, + hasSeenChangesToolTip: true, + hasSeenSimulatorToolTip: true, + hasSeenTutorialsToolTip: true, + authStatus: "unknown", + lastSyncChange: null, + ...reduxStorage, + } + : { + userReview: { + onboarding: onboarded, + advancedRepository: false, + }, + updatesViewed: { + latest: null, + latestNonBreaking: null, + }, + hasSeenChangesToolTip: false, + hasSeenSimulatorToolTip: false, + hasSeenTutorialsToolTip: false, + authStatus: "unknown", + lastSyncChange: null, + ...reduxStorage, + }; + + // Create new storage state + const SLICE_MACHINE_STORAGE_PREFIX = "slice-machine"; + const storageFormatted = Object.entries(storage).map(([key, value]) => ({ + name: `${SLICE_MACHINE_STORAGE_PREFIX}_${key}`, + value: JSON.stringify(value), + })); + const newStorage = onboarded + ? [ { - origin: config.use.baseURL, - localStorage: [ - { - name: "persist:root", - value: JSON.stringify({ - userContext: { - userReview: { - onboarding: true, - advancedRepository: true, - }, - updatesViewed: { - latest: null, - latestNonBreaking: null, - }, - hasSeenChangesToolTip: true, - hasSeenSimulatorToolTip: true, - hasSeenTutorialsToolTip: true, - authStatus: "unknown", - lastSyncChange: null, - }, - }), - }, - { - name: "slice-machine_isInAppGuideOpen", - value: "false", - }, - ], + name: `${SLICE_MACHINE_STORAGE_PREFIX}_isInAppGuideOpen`, + value: "false", }, - ], - }, - }); + ] + .filter( + (item) => + storageFormatted.findIndex( + (formattedItem) => formattedItem.name === item.name, + ) === -1, + ) + .concat(storageFormatted) + : storageFormatted; - // Prevent user to be onboarded if needed - if (!onboarded) { - context = await browser.newContext({ - storageState: { - cookies: [], - origins: [ + // Onboard user in Local Storage by default + const storageState = { + cookies: [], + origins: [ + { + origin: config.use.baseURL, + localStorage: [ { - origin: config.use.baseURL, - localStorage: [], + name: "persist:root", + value: JSON.stringify({ + userContext: JSON.stringify(userContext), + }), }, - ], + ].concat(newStorage), }, - }); - } + ], + }; // Create new page object with new context - const page = await context.newPage(); + const newContext = await browser.newContext({ storageState }); + const page = await newContext.newPage(); // Logout user by default try { @@ -212,99 +235,6 @@ export const defaultTest = ( // Ignore since it means the user is already logged out } - // Login user if needed - if (loggedIn) { - // In CI we define a PRISMIC_URL env variable to fasten the tests - let prismicUrl = process.env["PRISMIC_URL"]; - - // In local we get the Prismic URL from the browser, it helps to avoid - // switching manually between Wroom and Prismic - if (!prismicUrl) { - await page.route( - "*/**/_manager", - async (route) => { - const postDataBuffer = route.request().postDataBuffer() as Buffer; - const postData = decode(postDataBuffer) as Record< - "procedurePath", - unknown[] - >; - - if (postData.procedurePath[0] === "getState") { - const response = await route.fetch(); - const existingBody = await response.body(); - const existingData = ( - decode(existingBody) as Record<"data", unknown> - ).data as Record< - "env", - { - endpoints: { PrismicWroom: string }; - } - >; - - // Get Prismic URL from the response of the getState call - prismicUrl = existingData.env.endpoints.PrismicWroom; - - await route.continue(); - } - }, - // Ensure only the first getState call is intercepted - { - times: 1, - }, - ); - - // Visit the page to trigger the getState call and wait for it - await page.goto("/"); - await page.waitForResponse("*/**/_manager"); - } - - const activeEnv = prismicUrl?.includes("wroom") ? "WROOM" : "PRISMIC"; - const email = process.env[`${activeEnv}_EMAIL`]; - const password = process.env[`${activeEnv}_PASSWORD`]; - - if (!prismicUrl) { - console.warn("Could not find Prismic URL."); - } else if (!email || !password) { - console.warn( - `Missing EMAIL or PASSWORD environment variables for ${activeEnv} environment.`, - ); - } else { - // Do the authentication call - const res = await fetch( - new URL("./authentication/signin", prismicUrl).toString(), - { - method: "post", - body: JSON.stringify({ - email, - password, - }), - headers: { - "Content-Type": "application/json", - }, - }, - ); - - if (!res.headers.has("Set-Cookie")) { - // If the authentication fails, log the error - const reason = await res.text(); - console.error( - "Could not authenticate to prismic. Please check the credentials.", - reason, - ); - } else { - // If the authentication succeeded, save the cookies to persist it - await fs.writeFile( - path.join(os.homedir(), ".prismic"), - JSON.stringify({ - base: new URL(prismicUrl).toString(), - cookies: - res.headers.get("Set-Cookie")?.split(", ").join("; ") ?? "", - }), - ); - } - } - } - // Propagate the modified page to the test await use(page); }, diff --git a/playwright/mocks/emptyLibraries.ts b/playwright/mocks/emptyLibraries.ts deleted file mode 100644 index 900f370f4c..0000000000 --- a/playwright/mocks/emptyLibraries.ts +++ /dev/null @@ -1,13 +0,0 @@ -export const emptyLibraries = [ - { - name: "slices", - path: "slices", - isLocal: true, - components: [], - meta: { - isNodeModule: false, - isDownloaded: false, - isManual: true, - }, - }, -]; diff --git a/playwright/mocks/generateLibraries.ts b/playwright/mocks/generateLibraries.ts new file mode 100644 index 0000000000..e84ab35ac4 --- /dev/null +++ b/playwright/mocks/generateLibraries.ts @@ -0,0 +1,86 @@ +import { SharedSlice } from "@prismicio/types-internal/lib/customtypes"; + +import { generateRandomId } from "../utils"; + +type GenerateTypesArgs = { nbSlices: number }; + +export type Library = { + name: string; + path: string; + isLocal: boolean; + components: { + from: string; + href: string; + pathToSlice: string; + fileName: string | null; + extension: string | null; + model: SharedSlice; + screenshots: Record< + string, + { + hash: string; + data: Buffer; + } + >; + }[]; + meta: { + name?: string; + version?: string; + isNodeModule: boolean; + isDownloaded: boolean; + isManual: boolean; + }; +}; + +export function generateLibraries(args: GenerateTypesArgs): Library[] { + const { nbSlices } = args; + + return [ + { + name: "slices", + path: "slices", + isLocal: true, + components: Array.from({ length: nbSlices }, (_, n) => ({ + from: "slices", + href: "slices", + pathToSlice: "pathToSlice", + fileName: "fileName", + extension: "extension", + model: { + id: `test_slice_${n}_${generateRandomId()}`, + type: "SharedSlice", + name: `TestSlice${n}${generateRandomId()}`, + description: "ExternalVideoSlice", + variations: [ + { + id: `default`, + name: `Default`, + docURL: "...", + version: "sktwi1xtmkfgx8626", + description: "ExternalVideoSlice", + primary: { + video: { + type: "IntegrationFields", + config: { + catalog: "demo-sm-next-ecom--external_videos", + label: "Video", + }, + }, + }, + items: {}, + imageUrl: + "https://images.prismic.io/slice-machine/621a5ec4-0387-4bc5-9860-2dd46cbc07cd_default_ss.png?auto=compress,format", + }, + ], + }, + screenshots: {}, + mocks: [], + })), + meta: { + isNodeModule: false, + isDownloaded: false, + isManual: true, + }, + }, + ]; +} diff --git a/playwright/mocks/generateTypes.ts b/playwright/mocks/generateTypes.ts new file mode 100644 index 0000000000..061d71b679 --- /dev/null +++ b/playwright/mocks/generateTypes.ts @@ -0,0 +1,48 @@ +import { CustomType } from "@prismicio/types-internal/lib/customtypes"; + +import { Library } from "./generateLibraries"; +import { generateRandomId } from "../utils"; + +type GenerateTypesArgs = { + nbTypes: number; + format?: "custom" | "page"; + libraries?: Library[]; +}; + +export function generateTypes(args: GenerateTypesArgs): CustomType[] { + const { nbTypes, format = "page", libraries } = args; + + return Array.from( + { length: nbTypes }, + (_, n): CustomType => ({ + id: `MyType${n}ID${generateRandomId()}`, + label: `MyType${n}Label${generateRandomId()}`, + repeatable: false, + status: true, + json: { + Main: { + title: { + type: "Text", + config: { label: "Title", placeholder: "" }, + }, + ...(libraries + ? { + slices: { + type: "Slices", + fieldset: "Slice Zone", + config: { + choices: { + [libraries[0]?.components[0]?.model.id as string]: { + type: "SharedSlice", + }, + }, + }, + }, + } + : {}), + }, + }, + format, + }), + ); +} diff --git a/playwright/mocks/index.ts b/playwright/mocks/index.ts index 6d4fc340d2..2da7f90710 100644 --- a/playwright/mocks/index.ts +++ b/playwright/mocks/index.ts @@ -1,3 +1,3 @@ -export { emptyLibraries } from "./emptyLibraries"; +export { generateLibraries } from "./generateLibraries"; +export { generateTypes } from "./generateTypes"; export { environments } from "./environments"; -export { simpleCustomType } from "./simpleCustomType"; diff --git a/playwright/mocks/simpleCustomType.ts b/playwright/mocks/simpleCustomType.ts deleted file mode 100644 index 41be73139b..0000000000 --- a/playwright/mocks/simpleCustomType.ts +++ /dev/null @@ -1,15 +0,0 @@ -export const simpleCustomType = { - id: "footer", - label: "Footer", - repeatable: false, - status: true, - json: { - Main: { - title: { - type: "Text", - config: { label: "Title", placeholder: "" }, - }, - }, - }, - format: "custom", -}; diff --git a/playwright/package.json b/playwright/package.json index 83eebc84e2..0da38956e7 100644 --- a/playwright/package.json +++ b/playwright/package.json @@ -4,22 +4,23 @@ "description": "E2E tests for Slice Machine", "author": "Prismic (https://prismic.io)", "scripts": { + "audit": "echo \"No audit for playwright\" && true", + "depcheck": "depcheck --config=.depcheckrc", "lint": "eslint --max-warnings 0 --ext .js,.ts .", "test:e2e:install": "playwright install --with-deps chromium", "test:e2e:report": "playwright show-report", "test:e2e": "playwright test", "types": "tsc --noEmit", - "unit": "echo \"No unit tests for playwright\" && true", - "depcheck": "depcheck --config=.depcheckrc", - "audit": "echo \"No audit for playwright\" && true" + "test:e2e:merge-reports": "playwright merge-reports", + "unit": "echo \"No unit tests for playwright\" && true" }, "devDependencies": { "@msgpack/msgpack": "2.8.0", "@playwright/test": "1.39.0", + "@prismicio/types-internal": "2.2.0", "@typescript-eslint/eslint-plugin": "5.55.0", "@typescript-eslint/parser": "5.55.0", "depcheck": "1.4.3", - "dotenv": "16.3.1", "eslint": "8.37.0", "eslint-config-prettier": "9.0.0", "typescript": "4.9.5" diff --git a/playwright/pages/ChangesPage.ts b/playwright/pages/ChangesPage.ts index b0644b407e..fd62c168f5 100644 --- a/playwright/pages/ChangesPage.ts +++ b/playwright/pages/ChangesPage.ts @@ -10,6 +10,12 @@ export class ChangesPage extends SliceMachinePage { readonly notLoggedInTitle: Locator; readonly notAuthorizedTitle: Locator; readonly blankSlateTitle: Locator; + readonly unknownErrorMessage: Locator; + readonly softLimitTitle: Locator; + readonly softLimitCheckbox: Locator; + readonly softLimitButton: Locator; + readonly hardLimitTitle: Locator; + readonly hardLimitButton: Locator; constructor(page: Page) { super(page); @@ -42,6 +48,29 @@ export class ChangesPage extends SliceMachinePage { this.blankSlateTitle = page.getByText("Everything up-to-date", { exact: true, }); + this.unknownErrorMessage = page.getByText( + "Something went wrong when pushing your changes. Check your terminal logs.", + { + exact: true, + }, + ); + this.softLimitTitle = page.getByText("Confirm deletion", { + exact: true, + }); + this.softLimitCheckbox = page.getByText("Delete these Documents", { + exact: true, + }); + this.softLimitButton = page.getByRole("button", { + name: "Push changes", + exact: true, + }); + this.hardLimitTitle = page.getByText("Manual action required", { + exact: true, + }); + this.hardLimitButton = page.getByRole("button", { + name: "Try again", + exact: true, + }); } /** @@ -62,7 +91,14 @@ export class ChangesPage extends SliceMachinePage { async pushChanges() { await this.pushChangesButton.click(); await expect(this.pushChangesButton).toBeDisabled(); - await expect(this.pushedMessaged).toBeVisible(); + await this.checkPushedMessage(); + } + + async confirmDeleteDocuments() { + await this.softLimitCheckbox.click(); + await this.softLimitButton.click(); + await expect(this.softLimitTitle).not.toBeVisible(); + await this.checkPushedMessage(); } /** @@ -85,4 +121,9 @@ export class ChangesPage extends SliceMachinePage { this.getCustomType(id).getByText(status, { exact: true }), ).toBeVisible(); } + + async checkPushedMessage() { + await expect(this.pushedMessaged).toBeVisible(); + await expect(this.pushedMessaged).not.toBeVisible(); + } } diff --git a/playwright/pages/SliceMachinePage.ts b/playwright/pages/SliceMachinePage.ts index e9169cc893..e04bc0d603 100644 --- a/playwright/pages/SliceMachinePage.ts +++ b/playwright/pages/SliceMachinePage.ts @@ -1,6 +1,7 @@ -import { Locator, Page } from "@playwright/test"; +import { Locator, Page, expect } from "@playwright/test"; import { Menu } from "./components/Menu"; +import { ReviewDialog } from "./components/ReviewDialog"; import { InAppGuideDialog } from "./components/InAppGuideDialog"; import { LoginDialog } from "./components/LoginDialog"; @@ -8,6 +9,7 @@ export class SliceMachinePage { readonly page: Page; readonly appLayout: Locator; readonly menu: Menu; + readonly reviewDialog: ReviewDialog; readonly inAppGuideDialog: InAppGuideDialog; readonly loginDialog: LoginDialog; readonly body: Locator; @@ -19,6 +21,7 @@ export class SliceMachinePage { */ this.page = page; this.menu = new Menu(page); + this.reviewDialog = new ReviewDialog(page); this.inAppGuideDialog = new InAppGuideDialog(page); this.loginDialog = new LoginDialog(page); @@ -42,6 +45,7 @@ export class SliceMachinePage { */ async gotoDefaultPage() { await this.page.goto("/"); + await expect(this.breadcrumb).toBeVisible(); } /** diff --git a/playwright/pages/components/Menu.ts b/playwright/pages/components/Menu.ts index c8cc0212d2..b13052bbfa 100644 --- a/playwright/pages/components/Menu.ts +++ b/playwright/pages/components/Menu.ts @@ -13,6 +13,10 @@ export class Menu { readonly tutorialLink: Locator; readonly changelogLink: Locator; readonly appVersion: Locator; + readonly updatesAvailableTitle: Locator; + readonly updatesAvailableButton: Locator; + readonly tutorialVideoTooltipTitle: Locator; + readonly tutorialVideoTooltipCloseButton: Locator; constructor(page: Page) { /** @@ -47,12 +51,22 @@ export class Menu { exact: false, }); this.appVersion = this.menu.getByTestId("slicemachine-version"); + this.updatesAvailableTitle = this.menu.getByText("Updates Available", { + exact: true, + }); + this.updatesAvailableButton = this.menu.getByText("Learn more", { + exact: true, + }); + this.tutorialVideoTooltipTitle = page.getByText("Need Help?"); + this.tutorialVideoTooltipCloseButton = page.getByText("Got it"); } /** * Dynamic locators */ - // Handle dynamic locators here + getAppVersion(appVersion: string) { + return this.appVersion.getByText(`v${appVersion}`, { exact: true }); + } /** * Actions diff --git a/playwright/pages/components/ReviewDialog.ts b/playwright/pages/components/ReviewDialog.ts new file mode 100644 index 0000000000..4efe92ed38 --- /dev/null +++ b/playwright/pages/components/ReviewDialog.ts @@ -0,0 +1,49 @@ +import { Locator, Page, expect } from "@playwright/test"; + +import { Dialog } from "./Dialog"; + +export class ReviewDialog extends Dialog { + readonly messageTextarea: Locator; + + constructor(page: Page) { + super(page, { + title: "Share feedback", + }); + + /** + * Components + */ + // Handle components here + + /** + * Static locators + */ + this.messageTextarea = this.dialog.getByPlaceholder("Tell us more...", { + exact: true, + }); + } + + /** + * Dynamic locators + */ + // Handle dynamic locators here + + /** + * Actions + */ + async submitReview(args: { rating: number; message: string }) { + const { rating, message } = args; + await expect(this.title).toBeVisible(); + await this.dialog + .getByRole("button", { name: rating.toString(), exact: true }) + .click(); + await this.messageTextarea.fill(message); + await this.submitButton.click(); + await expect(this.title).not.toBeVisible(); + } + + /** + * Assertions + */ + // Handle assertions here +} diff --git a/playwright/playwright.config.ts b/playwright/playwright.config.ts index f9d6f0037e..90dc537743 100644 --- a/playwright/playwright.config.ts +++ b/playwright/playwright.config.ts @@ -12,9 +12,6 @@ const config = { // Fail the build on CI if you accidentally left test.only in the source code. forbidOnly: !!process.env["CI"], - // Retry on CI only. - retries: process.env["CI"] ? 2 : 0, - // Configure projects for major browsers. projects: [ { @@ -25,9 +22,12 @@ const config = { }, ], + // Retry on CI only. + retries: process.env["CI"] ? 2 : 0, + // Reporter to use. reporter: process.env["CI"] - ? [["github"], ["html"]] + ? [["github"], ["blob"]] : [ [ "html", @@ -55,18 +55,23 @@ const config = { baseURL: "http://localhost:9999", // Collect trace when retrying the failed test. - trace: "on", - video: "off", - screenshot: "on", + trace: "on-first-retry", + + // Setup the test id attribute to be `data-cy` for `getByTestId`. testIdAttribute: "data-cy", + + // Configure the browser permissions to access the clipboard API. + permissions: ["clipboard-read", "clipboard-write"], }, // Run local dev servers before starting the tests if needed. webServer: [ { cwd: "..", - command: "yarn dev", - url: `http://localhost:3000/`, + command: process.env["CI"] ? "yarn dev:e2e-next" : "yarn dev", + url: process.env["CI"] + ? "http://localhost:8000/" + : "http://localhost:3000/", reuseExistingServer: !process.env["CI"], stdout: "pipe", timeout: 120_000, @@ -74,16 +79,16 @@ const config = { { cwd: "../e2e-projects/next", command: "yarn slicemachine:dev", - url: `http://localhost:9999/`, + url: "http://localhost:9999/", reuseExistingServer: !process.env["CI"], stdout: "pipe", timeout: 120_000, }, ], - // Don't run tests in parallel due to the nature of - // Slice Machine modifying file in the file system. - workers: 1, + // Opt out of parallel tests on CI to ensure + // See: https://playwright.dev/docs/ci#workers + workers: process.env["CI"] ? 1 : undefined, } satisfies PlaywrightTestConfig; export default config; diff --git a/playwright/tests/changes/changes.spec.ts b/playwright/tests/changes/changes.spec.ts index 25a104c21a..df70d32df3 100644 --- a/playwright/tests/changes/changes.spec.ts +++ b/playwright/tests/changes/changes.spec.ts @@ -1,13 +1,27 @@ +import { CustomType } from "@prismicio/types-internal/lib/customtypes"; import { expect } from "@playwright/test"; import { test } from "../../fixtures"; import { mockManagerProcedures } from "../../utils"; -import { emptyLibraries, simpleCustomType } from "../../mocks"; +import { generateLibraries, generateTypes } from "../../mocks"; test.describe("Changes", () => { - test.run({ loggedIn: true })( + test.run()( "I cannot see the login screen when logged in", async ({ changesPage }) => { + await mockManagerProcedures({ + page: changesPage.page, + procedures: [ + { + path: "getState", + data: (data) => ({ + ...data, + clientError: undefined, + }), + }, + ], + }); + await changesPage.goto(); await expect(changesPage.loginButton).not.toBeVisible(); await expect(changesPage.notLoggedInTitle).not.toBeVisible(); @@ -18,13 +32,28 @@ test.describe("Changes", () => { test.run()( "I can see the login screen when logged out", async ({ changesPage }) => { + await mockManagerProcedures({ + page: changesPage.page, + procedures: [ + { + path: "getState", + data: (data) => ({ + ...data, + clientError: { + status: 401, + }, + }), + }, + ], + }); + await changesPage.goto(); await expect(changesPage.loginButton).toBeVisible(); await expect(changesPage.notLoggedInTitle).toBeVisible(); }, ); - test.run({ loggedIn: true })( + test.run()( "I can see the unauthorized screen when not authorized", async ({ changesPage }) => { await mockManagerProcedures({ @@ -48,7 +77,7 @@ test.describe("Changes", () => { }, ); - test.run({ loggedIn: true })( + test.run()( "I can see the empty state when I don't have any changes to push", async ({ changesPage }) => { await mockManagerProcedures({ @@ -58,10 +87,11 @@ test.describe("Changes", () => { path: "getState", data: (data) => ({ ...data, - libraries: emptyLibraries, + libraries: generateLibraries({ nbSlices: 0 }), customTypes: [], remoteCustomTypes: [], remoteSlices: [], + clientError: undefined, }), }, ], @@ -73,9 +103,11 @@ test.describe("Changes", () => { }, ); - test.run({ loggedIn: true })( + test.run()( "I can see the changes I have to push", async ({ changesPage }) => { + const types = generateTypes({ nbTypes: 1 }); + const customType = types[0] as CustomType; await mockManagerProcedures({ page: changesPage.page, procedures: [ @@ -83,10 +115,11 @@ test.describe("Changes", () => { path: "getState", data: (data) => ({ ...data, - libraries: emptyLibraries, - customTypes: [simpleCustomType], + libraries: generateLibraries({ nbSlices: 0 }), + customTypes: types, remoteCustomTypes: [], remoteSlices: [], + clientError: undefined, }), }, ], @@ -95,16 +128,117 @@ test.describe("Changes", () => { await changesPage.goto(); await expect(changesPage.loginButton).not.toBeVisible(); await changesPage.checkCustomTypeName( - simpleCustomType.id, - simpleCustomType.label, + customType.id, + customType.label as string, ); - await changesPage.checkCustomTypeApiId(simpleCustomType.id); - await changesPage.checkCustomTypeStatus(simpleCustomType.id, "New"); + await changesPage.checkCustomTypeApiId(customType.id); + await changesPage.checkCustomTypeStatus(customType.id, "New"); + }, + ); + + test.run()("I can push the changes I have", async ({ changesPage }) => { + await mockManagerProcedures({ + page: changesPage.page, + procedures: [ + { + path: "getState", + data: (data) => ({ + ...data, + libraries: generateLibraries({ nbSlices: 0 }), + customTypes: generateTypes({ nbTypes: 1 }), + remoteCustomTypes: [], + remoteSlices: [], + clientError: undefined, + }), + }, + { + path: "prismicRepository.pushChanges", + execute: false, + }, + ], + }); + + await changesPage.goto(); + await expect(changesPage.loginButton).not.toBeVisible(); + await changesPage.pushChanges(); + }); + + test.run()( + "I can see an error when the push failed", + async ({ changesPage }) => { + await mockManagerProcedures({ + page: changesPage.page, + procedures: [ + { + path: "getState", + data: (data) => ({ + ...data, + libraries: generateLibraries({ nbSlices: 0 }), + customTypes: generateTypes({ nbTypes: 1 }), + remoteCustomTypes: [], + remoteSlices: [], + clientError: undefined, + }), + }, + { + path: "prismicRepository.pushChanges", + data: () => new Error("Error"), + execute: false, + }, + ], + }); + + await changesPage.goto(); + await expect(changesPage.loginButton).not.toBeVisible(); + await changesPage.pushChangesButton.click(); + await expect(changesPage.unknownErrorMessage).toBeVisible(); + }, + ); + + test.run()( + "I have to confirm the push when I reach a soft limit of deleted documents", + async ({ changesPage }) => { + await mockManagerProcedures({ + page: changesPage.page, + procedures: [ + { + path: "getState", + data: (data) => ({ + ...data, + libraries: generateLibraries({ nbSlices: 0 }), + customTypes: generateTypes({ nbTypes: 1 }), + remoteCustomTypes: [], + remoteSlices: [], + clientError: undefined, + }), + }, + { + path: "prismicRepository.pushChanges", + data: () => ({ + type: "SOFT", + details: { + customTypes: [], + }, + }), + execute: false, + }, + { + path: "prismicRepository.pushChanges", + execute: false, + }, + ], + }); + + await changesPage.goto(); + await expect(changesPage.loginButton).not.toBeVisible(); + await changesPage.pushChangesButton.click(); + await expect(changesPage.softLimitTitle).toBeVisible(); + await changesPage.confirmDeleteDocuments(); }, ); - test.run({ loggedIn: true })( - "I can push the changes I have", + test.run()( + "I cannot push the changes when I reach a hard limit of deleted documents", async ({ changesPage }) => { await mockManagerProcedures({ page: changesPage.page, @@ -113,11 +247,22 @@ test.describe("Changes", () => { path: "getState", data: (data) => ({ ...data, - libraries: emptyLibraries, - customTypes: [simpleCustomType], + libraries: generateLibraries({ nbSlices: 0 }), + customTypes: generateTypes({ nbTypes: 1 }), remoteCustomTypes: [], remoteSlices: [], + clientError: undefined, + }), + }, + { + path: "prismicRepository.pushChanges", + data: () => ({ + type: "HARD", + details: { + customTypes: [], + }, }), + execute: false, }, { path: "prismicRepository.pushChanges", @@ -128,7 +273,11 @@ test.describe("Changes", () => { await changesPage.goto(); await expect(changesPage.loginButton).not.toBeVisible(); - await changesPage.pushChanges(); + await changesPage.pushChangesButton.click(); + + await expect(changesPage.hardLimitTitle).toBeVisible(); + await changesPage.hardLimitButton.click(); + await changesPage.checkPushedMessage(); }, ); }); diff --git a/playwright/tests/common/environment.spec.ts b/playwright/tests/common/environment.spec.ts index c2d25d34b5..4779adb9b7 100644 --- a/playwright/tests/common/environment.spec.ts +++ b/playwright/tests/common/environment.spec.ts @@ -161,45 +161,47 @@ test.describe("Environment", () => { }, ); - test.run()( - "I can see the window top border depending on the environment", - async ({ sliceMachinePage }) => { - await mockManagerProcedures({ - page: sliceMachinePage.page, - procedures: [ - { - path: "prismicRepository.fetchEnvironments", - data: () => ({ environments }), - execute: false, - }, - ], - }); - - await sliceMachinePage.gotoDefaultPage(); - - await sliceMachinePage.menu.environmentSelector.selectEnvironment( - environments[0].name, - ); - await expect(sliceMachinePage.appLayout).toHaveCSS( - "border-top-color", - "rgb(109, 84, 207)", - ); - - await sliceMachinePage.menu.environmentSelector.selectEnvironment( - environments[1].name, - ); - await expect(sliceMachinePage.appLayout).toHaveCSS( - "border-top-color", - "rgb(56, 91, 204)", - ); - - await sliceMachinePage.menu.environmentSelector.selectEnvironment( - environments[2].name, - ); - await expect(sliceMachinePage.appLayout).toHaveCSS( - "border-top-color", - "rgb(255, 159, 26)", - ); - }, - ); + test + .run() + .skip( + "I can see the window top border depending on the environment", + async ({ sliceMachinePage }) => { + await mockManagerProcedures({ + page: sliceMachinePage.page, + procedures: [ + { + path: "prismicRepository.fetchEnvironments", + data: () => ({ environments }), + execute: false, + }, + ], + }); + + await sliceMachinePage.gotoDefaultPage(); + + await sliceMachinePage.menu.environmentSelector.selectEnvironment( + environments[0].name, + ); + await expect(sliceMachinePage.appLayout).toHaveCSS( + "border-top-color", + "rgb(109, 84, 207)", + ); + + await sliceMachinePage.menu.environmentSelector.selectEnvironment( + environments[1].name, + ); + await expect(sliceMachinePage.appLayout).toHaveCSS( + "border-top-color", + "rgb(56, 91, 204)", + ); + + await sliceMachinePage.menu.environmentSelector.selectEnvironment( + environments[2].name, + ); + await expect(sliceMachinePage.appLayout).toHaveCSS( + "border-top-color", + "rgb(255, 159, 26)", + ); + }, + ); }); diff --git a/playwright/tests/common/navigation.spec.ts b/playwright/tests/common/navigation.spec.ts deleted file mode 100644 index e4109baa4e..0000000000 --- a/playwright/tests/common/navigation.spec.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { expect } from "@playwright/test"; - -import { test } from "../../fixtures"; - -test.describe("Navigation", () => { - test.run()( - "I can navigate through all menu entries", - async ({ - sliceMachinePage, - pageTypesTablePage, - customTypesTablePage, - slicesListPage, - changesPage, - changelogPage, - }) => { - await sliceMachinePage.gotoDefaultPage(); - - await pageTypesTablePage.menu.pageTypesLink.click(); - await expect(pageTypesTablePage.breadcrumbLabel).toBeVisible(); - expect(await sliceMachinePage.page.title()).toContain( - "Page types - Slice Machine", - ); - - await customTypesTablePage.menu.customTypesLink.click(); - await expect(customTypesTablePage.breadcrumbLabel).toBeVisible(); - expect(await sliceMachinePage.page.title()).toContain( - "Custom types - Slice Machine", - ); - - await slicesListPage.menu.slicesLink.click(); - await expect(slicesListPage.breadcrumbLabel).toBeVisible(); - expect(await sliceMachinePage.page.title()).toContain( - "Slices - Slice Machine", - ); - - await changesPage.menu.changesLink.click(); - await expect(changesPage.breadcrumbLabel).toBeVisible(); - expect(await sliceMachinePage.page.title()).toContain( - "Changes - Slice Machine", - ); - - await changelogPage.menu.changelogLink.click(); - await expect(changelogPage.breadcrumbLabel).toBeVisible(); - expect(await sliceMachinePage.page.title()).toContain( - "Changelog - Slice Machine", - ); - }, - ); - - // Unskip when we fix the Changelog fetching problem - DT-1794 - test - .run() - .skip( - "I access the changelog from Slice Machine version", - async ({ pageTypesTablePage, changelogPage }) => { - await pageTypesTablePage.goto(); - await expect(pageTypesTablePage.menu.appVersion).toBeVisible(); - await pageTypesTablePage.menu.appVersion.click(); - - await expect(changelogPage.breadcrumbLabel).toBeVisible(); - }, - ); -}); diff --git a/playwright/tests/common/reviewForm.spec.ts b/playwright/tests/common/reviewForm.spec.ts new file mode 100644 index 0000000000..99b2c060ad --- /dev/null +++ b/playwright/tests/common/reviewForm.spec.ts @@ -0,0 +1,158 @@ +import { expect } from "@playwright/test"; + +import { test } from "../../fixtures"; +import { mockManagerProcedures } from "../../utils"; +import { generateLibraries, generateTypes } from "../../mocks"; + +test.describe("Review form", () => { + test.run({ + onboarded: false, + reduxStorage: { + lastSyncChange: new Date(new Date().getTime() - 3600000).getTime(), + }, + })("I can write a review after onboarding", async ({ sliceMachinePage }) => { + const libraries = generateLibraries({ nbSlices: 1 }); + + // We mock a page type with a slice that is a requirement for the review dialog + await mockManagerProcedures({ + page: sliceMachinePage.page, + procedures: [ + { + path: "getState", + data: (data) => ({ + ...data, + libraries, + customTypes: generateTypes({ nbTypes: 1, libraries }), + remoteCustomTypes: [], + remoteSlices: [], + clientError: undefined, + }), + }, + ], + }); + + await sliceMachinePage.gotoDefaultPage(); + + // We close the in app guide to display the review dialog + await sliceMachinePage.inAppGuideDialog.closeButton.click(); + + await sliceMachinePage.reviewDialog.submitReview({ + rating: 4, + message: "Great job!", + }); + + // We verify that the review dialog is not displayed anymore + await sliceMachinePage.page.reload(); + await expect(sliceMachinePage.reviewDialog.title).not.toBeVisible(); + }); + + test.run({ + onboarded: false, + reduxStorage: { + lastSyncChange: new Date(new Date().getTime() - 3600000).getTime(), + }, + })( + "I can write a review after creating enough models", + async ({ sliceMachinePage }) => { + const libraries = generateLibraries({ nbSlices: 6 }); + + // We mock a page type with a slice that is a requirement for the review dialog + await mockManagerProcedures({ + page: sliceMachinePage.page, + procedures: [ + { + path: "getState", + data: (data) => ({ + ...data, + libraries, + customTypes: generateTypes({ nbTypes: 1, libraries }), + remoteCustomTypes: [], + remoteSlices: [], + clientError: undefined, + }), + }, + { + path: "getState", + data: (data) => ({ + ...data, + libraries, + customTypes: generateTypes({ nbTypes: 1, libraries }), + remoteCustomTypes: [], + remoteSlices: [], + clientError: undefined, + }), + }, + { + path: "getState", + data: (data) => ({ + ...data, + libraries, + customTypes: generateTypes({ nbTypes: 6, libraries }), + remoteCustomTypes: [], + remoteSlices: [], + clientError: undefined, + }), + }, + ], + }); + + await sliceMachinePage.gotoDefaultPage(); + + // We close the in app guide so the review dialog can be displayed + await sliceMachinePage.inAppGuideDialog.closeButton.click(); + + // We close the first review for onboarding + await sliceMachinePage.reviewDialog.closeButton.click(); + + // We reload and mock with 6 types to trigger the advanced review dialog + await sliceMachinePage.page.reload(); + + await sliceMachinePage.reviewDialog.submitReview({ + rating: 5, + message: "I love Slice Machine!", + }); + + // We verify that the review dialog is not displayed anymore + await sliceMachinePage.page.reload(); + await expect(sliceMachinePage.reviewDialog.title).not.toBeVisible(); + }, + ); + + test.run({ + onboarded: false, + reduxStorage: { + lastSyncChange: new Date(new Date().getTime() - 3600000).getTime(), + }, + })("I can close the review dialog", async ({ sliceMachinePage }) => { + const libraries = generateLibraries({ nbSlices: 1 }); + + // We mock a page type with a slice that is a requirement for the review dialog + await mockManagerProcedures({ + page: sliceMachinePage.page, + procedures: [ + { + path: "getState", + data: (data) => ({ + ...data, + libraries, + customTypes: generateTypes({ nbTypes: 1, libraries }), + remoteCustomTypes: [], + remoteSlices: [], + clientError: undefined, + }), + }, + ], + }); + + await sliceMachinePage.gotoDefaultPage(); + + // We close the in app guide to display the review dialog + await sliceMachinePage.inAppGuideDialog.closeButton.click(); + + await sliceMachinePage.reviewDialog.closeButton.click(); + + // We verify that the review dialog is not displayed anymore + await sliceMachinePage.page.reload(); + await expect(sliceMachinePage.reviewDialog.title).not.toBeVisible(); + }); +}); diff --git a/playwright/tests/common/sideNav.spec.ts b/playwright/tests/common/sideNav.spec.ts new file mode 100644 index 0000000000..81776eb286 --- /dev/null +++ b/playwright/tests/common/sideNav.spec.ts @@ -0,0 +1,173 @@ +import { expect } from "@playwright/test"; + +import { test } from "../../fixtures"; +import { mockManagerProcedures } from "../../utils/mockManagerProcedures"; +import { generateLibraries, generateTypes } from "../../mocks"; + +test.describe("Side nav", () => { + test.run()( + "I can navigate through all menu entries", + async ({ + sliceMachinePage, + pageTypesTablePage, + customTypesTablePage, + slicesListPage, + changesPage, + changelogPage, + }) => { + await sliceMachinePage.gotoDefaultPage(); + + await pageTypesTablePage.menu.pageTypesLink.click(); + await expect(pageTypesTablePage.breadcrumbLabel).toBeVisible(); + expect(await sliceMachinePage.page.title()).toContain( + "Page types - Slice Machine", + ); + + await customTypesTablePage.menu.customTypesLink.click(); + await expect(customTypesTablePage.breadcrumbLabel).toBeVisible(); + expect(await sliceMachinePage.page.title()).toContain( + "Custom types - Slice Machine", + ); + + await slicesListPage.menu.slicesLink.click(); + await expect(slicesListPage.breadcrumbLabel).toBeVisible(); + expect(await sliceMachinePage.page.title()).toContain( + "Slices - Slice Machine", + ); + + await changesPage.menu.changesLink.click(); + await expect(changesPage.breadcrumbLabel).toBeVisible(); + expect(await sliceMachinePage.page.title()).toContain( + "Changes - Slice Machine", + ); + + await changelogPage.menu.changelogLink.click(); + await expect(changelogPage.breadcrumbLabel).toBeVisible(); + expect(await sliceMachinePage.page.title()).toContain( + "Changelog - Slice Machine", + ); + }, + ); + + test.run()( + "I access the changelog from Slice Machine version", + async ({ pageTypesTablePage, changelogPage }) => { + await mockManagerProcedures({ + page: pageTypesTablePage.page, + procedures: [ + { + path: "versions.getRunningSliceMachineVersion", + data: () => "1.0.42", + execute: false, + }, + ], + }); + + await pageTypesTablePage.goto(); + await pageTypesTablePage.menu.getAppVersion("1.0.42").click(); + + await expect(changelogPage.breadcrumbLabel).toBeVisible(); + }, + ); + + test.run()( + "I can see the updates available warning and access changelog from it", + async ({ pageTypesTablePage, changelogPage }) => { + await mockManagerProcedures({ + page: pageTypesTablePage.page, + procedures: [ + { + path: "versions.checkIsSliceMachineUpdateAvailable", + data: () => true, + execute: false, + }, + ], + }); + + await pageTypesTablePage.goto(); + await expect(pageTypesTablePage.menu.updatesAvailableTitle).toBeVisible(); + await pageTypesTablePage.menu.updatesAvailableButton.click(); + + await expect(changelogPage.breadcrumbLabel).toBeVisible(); + }, + ); + + test.run()( + "I cannot see the updates available warning", + async ({ pageTypesTablePage }) => { + await mockManagerProcedures({ + page: pageTypesTablePage.page, + procedures: [ + { + path: "versions.checkIsAdapterUpdateAvailable", + data: () => false, + execute: false, + }, + { + path: "versions.checkIsSliceMachineUpdateAvailable", + data: () => false, + execute: false, + }, + ], + }); + + await pageTypesTablePage.goto(); + await expect(pageTypesTablePage.menu.appVersion).toBeVisible(); + await expect( + pageTypesTablePage.menu.updatesAvailableTitle, + ).not.toBeVisible(); + }, + ); + + test.run({ + onboarded: false, + reduxStorage: { + lastSyncChange: new Date(new Date().getTime() - 3600000).getTime(), + }, + })( + "I can close the tutorial video tooltip and it stays close", + async ({ sliceMachinePage }) => { + const libraries = generateLibraries({ nbSlices: 1 }); + + // We mock a page type with a slice that is a requirement for the review dialog + await mockManagerProcedures({ + page: sliceMachinePage.page, + procedures: [ + { + path: "getState", + data: (data) => ({ + ...data, + libraries, + customTypes: generateTypes({ nbTypes: 1, libraries }), + remoteCustomTypes: [], + remoteSlices: [], + clientError: undefined, + }), + }, + ], + }); + + await sliceMachinePage.gotoDefaultPage(); + + // We close the in app guide and review dialogs that are requirements for the tutorial tooltip display + await sliceMachinePage.inAppGuideDialog.closeButton.click(); + await sliceMachinePage.reviewDialog.closeButton.click(); + + // Then tutorial tooltip open after the review dialog + await expect( + sliceMachinePage.menu.tutorialVideoTooltipTitle, + ).toBeVisible(); + await sliceMachinePage.menu.tutorialVideoTooltipCloseButton.click(); + await expect( + sliceMachinePage.menu.tutorialVideoTooltipTitle, + ).not.toBeVisible(); + + await sliceMachinePage.page.reload(); + await expect(sliceMachinePage.menu.pageTypesLink).toBeVisible(); + + await expect( + sliceMachinePage.menu.tutorialVideoTooltipTitle, + ).not.toBeVisible(); + }, + ); +}); diff --git a/playwright/utils/mockManagerProcedures.ts b/playwright/utils/mockManagerProcedures.ts index 79a40f8c4b..6add77a290 100644 --- a/playwright/utils/mockManagerProcedures.ts +++ b/playwright/utils/mockManagerProcedures.ts @@ -1,5 +1,5 @@ import { encode, decode } from "@msgpack/msgpack"; -import { Page } from "@playwright/test"; +import { BrowserContext, Page } from "@playwright/test"; type MockManagerProceduresArgs = { /** @@ -19,7 +19,14 @@ type MockManagerProceduresArgs = { /** * Function that takes the existing data and returns the data to return. */ - data?: (data: Record) => Record; + data?: ( + data: Record, + ) => + | Record + | Record[] + | string + | boolean + | Error; /** * Whether to execute the procedure or not. Defaults to true. @@ -29,10 +36,13 @@ type MockManagerProceduresArgs = { }; /** - * Mocks manager procedures from SliceMachineManagerClient + * Mocks manager procedures from SliceMachineManagerClient. + * If you mock multiple procedures with the same path, they will be executed in order. + * The last one will be executed for all subsequent calls non directly covered. */ export async function mockManagerProcedures(args: MockManagerProceduresArgs) { const { page, procedures } = args; + let proceduresCountObject: Record = {}; await page.route("*/**/_manager", async (route) => { const postDataBuffer = route.request().postDataBuffer() as Buffer; @@ -40,13 +50,28 @@ export async function mockManagerProcedures(args: MockManagerProceduresArgs) { "procedurePath", unknown[] >; + const procedurePath = postData.procedurePath.join("."); + const procedureCount: number = proceduresCountObject[procedurePath] ?? 0; - const procedure = procedures.find( - (p) => p.path === postData.procedurePath.join("."), + const matchedProcedures = procedures.filter( + (p) => p.path === procedurePath, ); + let procedure = + matchedProcedures.length > 1 + ? matchedProcedures[procedureCount] + : matchedProcedures[0]; + + if (!procedure && matchedProcedures.length > 1) { + procedure = matchedProcedures[matchedProcedures.length - 1]; + } + if (procedure) { const { data, execute = true } = procedure; + proceduresCountObject = { + ...proceduresCountObject, + [procedure.path]: procedureCount + 1, + }; let newBody = Buffer.from( encode( @@ -59,17 +84,22 @@ export async function mockManagerProcedures(args: MockManagerProceduresArgs) { ); if (execute) { - const response = await route.fetch(); - const existingBody = await response.body(); - const existingData = (decode(existingBody) as Record<"data", unknown>) - .data as Record; + try { + const response = await route.fetch(); + const existingBody = await response.body(); + const existingData = (decode(existingBody) as Record<"data", unknown>) + .data as Record; - if (data) { - newBody = Buffer.from( - encode({ - data: data(existingData), - }), - ); + if (data) { + newBody = Buffer.from( + encode({ + data: data(existingData), + }), + ); + } + } catch (error) { + // noop: when the test end, it can happen a route is still pending and + // we want to prevent executing a request if the context is closed. } } diff --git a/yarn.lock b/yarn.lock index 8c1e60e6bf..045eb66e7e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8329,10 +8329,10 @@ __metadata: dependencies: "@msgpack/msgpack": 2.8.0 "@playwright/test": 1.39.0 + "@prismicio/types-internal": 2.2.0 "@typescript-eslint/eslint-plugin": 5.55.0 "@typescript-eslint/parser": 5.55.0 depcheck: 1.4.3 - dotenv: 16.3.1 eslint: 8.37.0 eslint-config-prettier: 9.0.0 typescript: 4.9.5