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..68b016a833 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -152,6 +152,10 @@ jobs: e2e: needs: prepare 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/slices/01-duplicates.cy.js b/cypress/e2e/slices/01-duplicates.cy.js index 39b105a1d9..5c0874eb48 100644 --- a/cypress/e2e/slices/01-duplicates.cy.js +++ b/cypress/e2e/slices/01-duplicates.cy.js @@ -2,7 +2,7 @@ const sliceName = "DuplicateSlices"; const sliceId = "duplicate_slices"; const lib = ".--slices"; -describe("Duplicate Slices", () => { +describe.skip("Duplicate 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_custom_screenshots.cy.js b/cypress/e2e/user-flows/scenario_custom_screenshots.cy.js index fc1308ca28..644f604f8d 100644 --- a/cypress/e2e/user-flows/scenario_custom_screenshots.cy.js +++ b/cypress/e2e/user-flows/scenario_custom_screenshots.cy.js @@ -4,7 +4,7 @@ import { menu } from "../../pages/menu"; import { sliceBuilder } from "../../pages/slices/sliceBuilder"; import { changesPage } from "../../pages/changes/changesPage"; -describe("I am an existing SM user and I want to upload screenshots on variations of an existing Slice", () => { +describe.skip("I am an existing SM user and I want to upload screenshots on variations of an existing Slice", () => { let slice; const initSliceData = (random = Date.now()) => ({ id: `test_custom_screenshots${random}`, 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 de02a6a7d0..b098ed7766 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 `procedures.mock` @@ -175,7 +192,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 @@ -251,6 +269,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 f396ae3043..2a4884c770 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"; @@ -18,8 +16,6 @@ import { generateRandomId } from "../utils/generateRandomId"; import config from "../playwright.config"; import { MockManagerProcedures } from "../utils"; -dotenv.config({ path: `.env.local` }); - export type DefaultFixtures = { /** * Pages @@ -53,9 +49,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({ /** @@ -155,61 +155,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 { @@ -218,99 +241,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..368ad1506b --- /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 = { slicesCount: 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 { slicesCount } = args; + + return [ + { + name: "slices", + path: "slices", + isLocal: true, + components: Array.from({ length: slicesCount }, (_, 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..9853eb0b1c --- /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 = { + typesCount: number; + format?: "custom" | "page"; + libraries?: Library[]; +}; + +export function generateTypes(args: GenerateTypesArgs): CustomType[] { + const { typesCount, format = "page", libraries } = args; + + return Array.from( + { length: typesCount }, + (_, 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 2a614a59b9..399c7c75cc 100644 --- a/playwright/pages/SliceMachinePage.ts +++ b/playwright/pages/SliceMachinePage.ts @@ -1,12 +1,14 @@ -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"; export class SliceMachinePage { readonly page: Page; readonly menu: Menu; + readonly reviewDialog: ReviewDialog; readonly inAppGuideDialog: InAppGuideDialog; readonly loginDialog: LoginDialog; readonly body: Locator; @@ -18,6 +20,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); @@ -44,6 +47,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..5ce4124817 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 prioritize stability and reproducibility. + // 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 dc87ac5def..a564dbe13c 100644 --- a/playwright/tests/changes/changes.spec.ts +++ b/playwright/tests/changes/changes.spec.ts @@ -1,12 +1,20 @@ +import { CustomType } from "@prismicio/types-internal/lib/customtypes"; import { expect } from "@playwright/test"; import { test } from "../../fixtures"; -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 }) => { + async ({ changesPage, procedures }) => { + procedures.mock("getState", ({ data }) => ({ + ...(data as Record), + clientError: { + clientError: undefined, + }, + })); + await changesPage.goto(); await expect(changesPage.loginButton).not.toBeVisible(); await expect(changesPage.notLoggedInTitle).not.toBeVisible(); @@ -16,14 +24,21 @@ test.describe("Changes", () => { test.run()( "I can see the login screen when logged out", - async ({ changesPage }) => { + async ({ changesPage, procedures }) => { + procedures.mock("getState", ({ data }) => ({ + ...(data as Record), + 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, procedures }) => { procedures.mock("getState", ({ data }) => ({ @@ -39,15 +54,16 @@ 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, procedures }) => { procedures.mock("getState", ({ data }) => ({ ...(data as Record), - libraries: emptyLibraries, + libraries: generateLibraries({ slicesCount: 0 }), customTypes: [], remoteCustomTypes: [], remoteSlices: [], + clientError: undefined, })); await changesPage.goto(); @@ -56,37 +72,41 @@ test.describe("Changes", () => { }, ); - test.run({ loggedIn: true })( + test.run()( "I can see the changes I have to push", async ({ changesPage, procedures }) => { + const types = generateTypes({ typesCount: 1 }); + const customType = types[0] as CustomType; procedures.mock("getState", ({ data }) => ({ ...(data as Record), - libraries: emptyLibraries, - customTypes: [simpleCustomType], + libraries: generateLibraries({ slicesCount: 0 }), + customTypes: types, remoteCustomTypes: [], remoteSlices: [], + clientError: undefined, })); 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({ loggedIn: true })( + test.run()( "I can push the changes I have", async ({ changesPage, procedures }) => { procedures.mock("getState", ({ data }) => ({ ...(data as Record), - libraries: emptyLibraries, - customTypes: [simpleCustomType], + libraries: generateLibraries({ slicesCount: 0 }), + customTypes: generateTypes({ typesCount: 1 }), remoteCustomTypes: [], remoteSlices: [], + clientError: undefined, })); procedures.mock("prismicRepository.pushChanges", () => undefined, { execute: false, @@ -97,4 +117,104 @@ test.describe("Changes", () => { await changesPage.pushChanges(); }, ); + + test.run()( + "I can see an error when the push failed", + async ({ changesPage, procedures }) => { + procedures.mock("getState", ({ data }) => ({ + ...(data as Record), + libraries: generateLibraries({ slicesCount: 0 }), + customTypes: generateTypes({ typesCount: 1 }), + remoteCustomTypes: [], + remoteSlices: [], + clientError: undefined, + })); + procedures.mock( + "prismicRepository.pushChanges", + () => 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, procedures }) => { + procedures.mock("getState", ({ data }) => ({ + ...(data as Record), + libraries: generateLibraries({ slicesCount: 0 }), + customTypes: generateTypes({ typesCount: 1 }), + remoteCustomTypes: [], + remoteSlices: [], + clientError: undefined, + })); + procedures.mock( + "prismicRepository.pushChanges", + () => ({ + type: "SOFT", + details: { + customTypes: [], + }, + }), + { + execute: false, + }, + ); + + await changesPage.goto(); + await expect(changesPage.loginButton).not.toBeVisible(); + await changesPage.pushChangesButton.click(); + await expect(changesPage.softLimitTitle).toBeVisible(); + + procedures.mock("prismicRepository.pushChanges", () => undefined, { + execute: false, + }); + + await changesPage.confirmDeleteDocuments(); + }, + ); + + test.run()( + "I cannot push the changes when I reach a hard limit of deleted documents", + async ({ changesPage, procedures }) => { + procedures.mock("getState", ({ data }) => ({ + ...(data as Record), + libraries: generateLibraries({ slicesCount: 0 }), + customTypes: generateTypes({ typesCount: 1 }), + remoteCustomTypes: [], + remoteSlices: [], + clientError: undefined, + })); + + procedures.mock( + "prismicRepository.pushChanges", + () => ({ + type: "HARD", + details: { + customTypes: [], + }, + }), + { execute: false }, + ); + + await changesPage.goto(); + await expect(changesPage.loginButton).not.toBeVisible(); + await changesPage.pushChangesButton.click(); + + procedures.mock("prismicRepository.pushChanges", () => undefined, { + execute: false, + }); + + await expect(changesPage.hardLimitTitle).toBeVisible(); + await changesPage.hardLimitButton.click(); + await changesPage.checkPushedMessage(); + }, + ); }); 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..a0fb3dce86 --- /dev/null +++ b/playwright/tests/common/reviewForm.spec.ts @@ -0,0 +1,130 @@ +import { expect } from "@playwright/test"; + +import { test } from "../../fixtures"; +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, procedures }) => { + const libraries = generateLibraries({ slicesCount: 1 }); + + // We mock a page type with a slice that is a requirement for the review dialog + procedures.mock("getState", ({ data }) => ({ + ...(data as Record), + libraries, + customTypes: generateTypes({ typesCount: 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, procedures }) => { + const libraries = generateLibraries({ slicesCount: 6 }); + + // We mock a page type with a slice that is a requirement for the review dialog + procedures.mock( + "getState", + ({ data }) => ({ + ...(data as Record), + libraries, + customTypes: generateTypes({ typesCount: 1, libraries }), + remoteCustomTypes: [], + remoteSlices: [], + clientError: undefined, + }), + { times: 2 }, + ); + + 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(); + + procedures.mock("getState", ({ data }) => ({ + ...(data as Record), + libraries, + customTypes: generateTypes({ typesCount: 6, libraries }), + remoteCustomTypes: [], + remoteSlices: [], + clientError: undefined, + })); + + // 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, procedures }) => { + const libraries = generateLibraries({ slicesCount: 1 }); + + // We mock a page type with a slice that is a requirement for the review dialog + procedures.mock("getState", ({ data }) => ({ + ...(data as Record), + libraries, + customTypes: generateTypes({ typesCount: 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..5e2538e570 --- /dev/null +++ b/playwright/tests/common/sideNav.spec.ts @@ -0,0 +1,148 @@ +import { expect } from "@playwright/test"; + +import { test } from "../../fixtures"; +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, procedures }) => { + procedures.mock( + "versions.getRunningSliceMachineVersion", + () => "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, procedures }) => { + procedures.mock( + "versions.checkIsSliceMachineUpdateAvailable", + () => 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, procedures }) => { + procedures.mock("versions.checkIsAdapterUpdateAvailable", () => false, { + execute: false, + }); + + procedures.mock( + "versions.checkIsSliceMachineUpdateAvailable", + () => 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, procedures }) => { + const libraries = generateLibraries({ slicesCount: 1 }); + + // We mock a page type with a slice that is a requirement for the review dialog + procedures.mock("getState", ({ data }) => ({ + ...(data as Record), + libraries, + customTypes: generateTypes({ typesCount: 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 9cdca6cec6..bf464bedaf 100644 --- a/playwright/utils/MockManagerProcedures.ts +++ b/playwright/utils/MockManagerProcedures.ts @@ -71,10 +71,16 @@ export class MockManagerProcedures { let existingData: unknown = undefined; if (procedure.config.execute ?? true) { - const response = await route.fetch(); - const existingBody = await response.body(); - existingData = (decode(existingBody) as Record<"data", unknown>) - .data as Record; + try { + const response = await route.fetch(); + const existingBody = await response.body(); + existingData = (decode(existingBody) as Record<"data", unknown>) + .data as Record; + } catch (_) { + // noop: when a test end, it can happen that a route is still pending and + // the request is aborted. Without this protection, if a request is + // made after the context is closed, the CI will fail. + } } let newBodyContents: Record = { data: existingData }; 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