From 1d0fc29b830c9fef6574afae4771c0039b45137b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Hamburger=20Gr=C3=B8ngaard?= Date: Tue, 24 Sep 2024 11:59:52 +0200 Subject: [PATCH] test: only run the collaborative env when needed In an effort to reduce the flakiness of the tests, we now only run tests with multiple pages (multiple editors) when needed. --- .../annotations-across-blocks.feature | 2 +- .../annotations-collaboration.feature | 16 + .../annotations-collaboration.test.ts | 11 + .../__tests__/annotations-edge-cases.feature | 13 +- ...annotations-overlapping-decorators.feature | 2 +- .../__tests__/annotations-overlapping.feature | 2 +- .../e2e-tests/__tests__/annotations.feature | 2 +- .../e2e-tests/__tests__/annotations.test.ts | 2 +- .../e2e-tests/__tests__/block-objects.feature | 2 +- .../e2e-tests/__tests__/block-objects.test.ts | 2 +- .../e2e-tests/__tests__/decorators.feature | 2 +- .../e2e-tests/__tests__/decorators.test.ts | 2 +- .../__tests__/gherkin-step-definitions.ts | 5 + .../__tests__/inline-objects.feature | 2 +- .../__tests__/inline-objects.test.ts | 2 +- .../__tests__/removing-blocks.feature | 2 +- .../__tests__/removing-blocks.test.ts | 2 +- .../__tests__/splitting-blocks.feature | 2 +- .../__tests__/splitting-blocks.test.ts | 2 +- .../__tests__/undo-redo-collaboration.feature | 46 +++ .../__tests__/undo-redo-collaboration.test.ts | 11 + .../e2e-tests/__tests__/undo-redo.feature | 43 +-- .../e2e-tests/__tests__/undo-redo.test.ts | 2 +- .../e2e-tests/setup/collaborative.jest.env.ts | 313 +----------------- .../editor/e2e-tests/setup/get-page-editor.ts | 302 +++++++++++++++++ .../editor/e2e-tests/setup/globals.jest.ts | 1 + packages/editor/e2e-tests/setup/jest.env.ts | 170 ++++++++++ 27 files changed, 596 insertions(+), 367 deletions(-) create mode 100644 packages/editor/e2e-tests/__tests__/annotations-collaboration.feature create mode 100644 packages/editor/e2e-tests/__tests__/annotations-collaboration.test.ts create mode 100644 packages/editor/e2e-tests/__tests__/undo-redo-collaboration.feature create mode 100644 packages/editor/e2e-tests/__tests__/undo-redo-collaboration.test.ts create mode 100644 packages/editor/e2e-tests/setup/get-page-editor.ts create mode 100644 packages/editor/e2e-tests/setup/jest.env.ts diff --git a/packages/editor/e2e-tests/__tests__/annotations-across-blocks.feature b/packages/editor/e2e-tests/__tests__/annotations-across-blocks.feature index 3ff9b21d..3c553f0f 100644 --- a/packages/editor/e2e-tests/__tests__/annotations-across-blocks.feature +++ b/packages/editor/e2e-tests/__tests__/annotations-across-blocks.feature @@ -1,7 +1,7 @@ Feature: Annotations Across Blocks Background: - Given two editors + Given one editor And a global keymap Scenario: Adding annotation across blocks diff --git a/packages/editor/e2e-tests/__tests__/annotations-collaboration.feature b/packages/editor/e2e-tests/__tests__/annotations-collaboration.feature new file mode 100644 index 00000000..e0669d2c --- /dev/null +++ b/packages/editor/e2e-tests/__tests__/annotations-collaboration.feature @@ -0,0 +1,16 @@ +Feature: Annotations Collaboration + + Background: + Given two editors + And a global keymap + + Scenario: Editor B inserts text after Editor A's half-deleted annotation + Given the text "foo" + And a "comment" "c1" around "foo" + When the caret is put after "foo" + And "Backspace" is pressed + And the caret is put after "fo" by editor B + And "a" is typed by editor B + Then the text is "fo,a" + And "fo" has marks "c1" + And "a" has no marks diff --git a/packages/editor/e2e-tests/__tests__/annotations-collaboration.test.ts b/packages/editor/e2e-tests/__tests__/annotations-collaboration.test.ts new file mode 100644 index 00000000..1ef0dac7 --- /dev/null +++ b/packages/editor/e2e-tests/__tests__/annotations-collaboration.test.ts @@ -0,0 +1,11 @@ +/** @jest-environment ./setup/collaborative.jest.env.ts */ + +import {Feature} from '@sanity/gherkin-driver/jest' +import annotationsCollaboration from './annotations-collaboration.feature' +import {parameterTypes, stepDefinitions} from './gherkin-step-definitions' + +Feature({ + featureText: annotationsCollaboration, + stepDefinitions, + parameterTypes, +}) diff --git a/packages/editor/e2e-tests/__tests__/annotations-edge-cases.feature b/packages/editor/e2e-tests/__tests__/annotations-edge-cases.feature index f845c7f0..9b181482 100644 --- a/packages/editor/e2e-tests/__tests__/annotations-edge-cases.feature +++ b/packages/editor/e2e-tests/__tests__/annotations-edge-cases.feature @@ -1,20 +1,9 @@ Feature: Annotations Edge Cases Background: - Given two editors + Given one editor And a global keymap - Scenario: Editor B inserts text after Editor A's half-deleted annotation - Given the text "foo" - And a "comment" "c1" around "foo" - When the caret is put after "foo" - And "Backspace" is pressed - And the caret is put after "fo" by editor B - And "a" is typed by editor B - Then the text is "fo,a" - And "fo" has marks "c1" - And "a" has no marks - Scenario: Deleting emphasised paragraph with comment in the middle Given the text "foo bar baz" And "em" around "foo bar baz" diff --git a/packages/editor/e2e-tests/__tests__/annotations-overlapping-decorators.feature b/packages/editor/e2e-tests/__tests__/annotations-overlapping-decorators.feature index 77cb668c..237019c5 100644 --- a/packages/editor/e2e-tests/__tests__/annotations-overlapping-decorators.feature +++ b/packages/editor/e2e-tests/__tests__/annotations-overlapping-decorators.feature @@ -1,7 +1,7 @@ Feature: Annotations Overlapping Decorators Background: - Given two editors + Given one editor And a global keymap Scenario: Annotation and decorator on the same text diff --git a/packages/editor/e2e-tests/__tests__/annotations-overlapping.feature b/packages/editor/e2e-tests/__tests__/annotations-overlapping.feature index 044b060c..93929380 100644 --- a/packages/editor/e2e-tests/__tests__/annotations-overlapping.feature +++ b/packages/editor/e2e-tests/__tests__/annotations-overlapping.feature @@ -1,7 +1,7 @@ Feature: Overlapping Annotations Background: - Given two editors + Given one editor And a global keymap Scenario: Overlapping annotation diff --git a/packages/editor/e2e-tests/__tests__/annotations.feature b/packages/editor/e2e-tests/__tests__/annotations.feature index 7c87e8c1..54afe6dd 100644 --- a/packages/editor/e2e-tests/__tests__/annotations.feature +++ b/packages/editor/e2e-tests/__tests__/annotations.feature @@ -2,7 +2,7 @@ Feature: Annotations Background: - Given two editors + Given one editor And a global keymap Scenario: Selection after adding an annotation diff --git a/packages/editor/e2e-tests/__tests__/annotations.test.ts b/packages/editor/e2e-tests/__tests__/annotations.test.ts index d54ffecd..0c072b1d 100644 --- a/packages/editor/e2e-tests/__tests__/annotations.test.ts +++ b/packages/editor/e2e-tests/__tests__/annotations.test.ts @@ -1,4 +1,4 @@ -/** @jest-environment ./setup/collaborative.jest.env.ts */ +/** @jest-environment ./setup/jest.env.ts */ import {Feature} from '@sanity/gherkin-driver/jest' import annotationsAcrossBlocks from './annotations-across-blocks.feature' diff --git a/packages/editor/e2e-tests/__tests__/block-objects.feature b/packages/editor/e2e-tests/__tests__/block-objects.feature index cc450f46..5d3fe19a 100644 --- a/packages/editor/e2e-tests/__tests__/block-objects.feature +++ b/packages/editor/e2e-tests/__tests__/block-objects.feature @@ -2,7 +2,7 @@ Feature: Block Objects Background: - Given two editors + Given one editor And a global keymap Scenario: Pressing ArrowUp on a lonely image diff --git a/packages/editor/e2e-tests/__tests__/block-objects.test.ts b/packages/editor/e2e-tests/__tests__/block-objects.test.ts index 87923c4b..fae2def2 100644 --- a/packages/editor/e2e-tests/__tests__/block-objects.test.ts +++ b/packages/editor/e2e-tests/__tests__/block-objects.test.ts @@ -1,4 +1,4 @@ -/** @jest-environment ./setup/collaborative.jest.env.ts */ +/** @jest-environment ./setup/jest.env.ts */ import {Feature} from '@sanity/gherkin-driver/jest' import featureFile from './block-objects.feature' diff --git a/packages/editor/e2e-tests/__tests__/decorators.feature b/packages/editor/e2e-tests/__tests__/decorators.feature index cc8f8d05..47ac0f32 100644 --- a/packages/editor/e2e-tests/__tests__/decorators.feature +++ b/packages/editor/e2e-tests/__tests__/decorators.feature @@ -2,7 +2,7 @@ Feature: Decorators Background: - Given two editors + Given one editor And a global keymap Scenario: Inserting text after a decorator diff --git a/packages/editor/e2e-tests/__tests__/decorators.test.ts b/packages/editor/e2e-tests/__tests__/decorators.test.ts index a15707ae..b0a6ec88 100644 --- a/packages/editor/e2e-tests/__tests__/decorators.test.ts +++ b/packages/editor/e2e-tests/__tests__/decorators.test.ts @@ -1,4 +1,4 @@ -/** @jest-environment ./setup/collaborative.jest.env.ts */ +/** @jest-environment ./setup/jest.env.ts */ import {Feature} from '@sanity/gherkin-driver/jest' import featureFile from './decorators.feature' diff --git a/packages/editor/e2e-tests/__tests__/gherkin-step-definitions.ts b/packages/editor/e2e-tests/__tests__/gherkin-step-definitions.ts index b15d1e31..388ba110 100644 --- a/packages/editor/e2e-tests/__tests__/gherkin-step-definitions.ts +++ b/packages/editor/e2e-tests/__tests__/gherkin-step-definitions.ts @@ -38,6 +38,11 @@ export const stepDefinitions = [ context.keyMap = new Map() }), + Given('one editor', async (context) => { + const editor = await getEditor() + context.editorA = editor + }), + Given('two editors', async (context) => { const [editorA, editorB] = await getEditors() context.editorA = editorA diff --git a/packages/editor/e2e-tests/__tests__/inline-objects.feature b/packages/editor/e2e-tests/__tests__/inline-objects.feature index 367e0b39..65969cad 100644 --- a/packages/editor/e2e-tests/__tests__/inline-objects.feature +++ b/packages/editor/e2e-tests/__tests__/inline-objects.feature @@ -2,7 +2,7 @@ Feature: Inline Objects Background: - Given two editors + Given one editor And a global keymap # Currently fails diff --git a/packages/editor/e2e-tests/__tests__/inline-objects.test.ts b/packages/editor/e2e-tests/__tests__/inline-objects.test.ts index b2a1df18..e5237cd1 100644 --- a/packages/editor/e2e-tests/__tests__/inline-objects.test.ts +++ b/packages/editor/e2e-tests/__tests__/inline-objects.test.ts @@ -1,4 +1,4 @@ -/** @jest-environment ./setup/collaborative.jest.env.ts */ +/** @jest-environment ./setup/jest.env.ts */ import {Feature} from '@sanity/gherkin-driver/jest' import {parameterTypes, stepDefinitions} from './gherkin-step-definitions' diff --git a/packages/editor/e2e-tests/__tests__/removing-blocks.feature b/packages/editor/e2e-tests/__tests__/removing-blocks.feature index e1e6c0d5..35dee637 100644 --- a/packages/editor/e2e-tests/__tests__/removing-blocks.feature +++ b/packages/editor/e2e-tests/__tests__/removing-blocks.feature @@ -1,7 +1,7 @@ Feature: Removing Blocks Background: - Given two editors + Given one editor And a global keymap Scenario: Pressing Delete in empty block with text below diff --git a/packages/editor/e2e-tests/__tests__/removing-blocks.test.ts b/packages/editor/e2e-tests/__tests__/removing-blocks.test.ts index f1fdcc56..bb19efc8 100644 --- a/packages/editor/e2e-tests/__tests__/removing-blocks.test.ts +++ b/packages/editor/e2e-tests/__tests__/removing-blocks.test.ts @@ -1,4 +1,4 @@ -/** @jest-environment ./setup/collaborative.jest.env.ts */ +/** @jest-environment ./setup/jest.env.ts */ import {Feature} from '@sanity/gherkin-driver/jest' import {parameterTypes, stepDefinitions} from './gherkin-step-definitions' diff --git a/packages/editor/e2e-tests/__tests__/splitting-blocks.feature b/packages/editor/e2e-tests/__tests__/splitting-blocks.feature index 1dddc098..0dc5e209 100644 --- a/packages/editor/e2e-tests/__tests__/splitting-blocks.feature +++ b/packages/editor/e2e-tests/__tests__/splitting-blocks.feature @@ -1,7 +1,7 @@ Feature: Splitting Blocks Background: - Given two editors + Given one editor And a global keymap Scenario: Splitting block at the beginning diff --git a/packages/editor/e2e-tests/__tests__/splitting-blocks.test.ts b/packages/editor/e2e-tests/__tests__/splitting-blocks.test.ts index 3fe6e7f1..c60deabf 100644 --- a/packages/editor/e2e-tests/__tests__/splitting-blocks.test.ts +++ b/packages/editor/e2e-tests/__tests__/splitting-blocks.test.ts @@ -1,4 +1,4 @@ -/** @jest-environment ./setup/collaborative.jest.env.ts */ +/** @jest-environment ./setup/jest.env.ts */ import {Feature} from '@sanity/gherkin-driver/jest' import {parameterTypes, stepDefinitions} from './gherkin-step-definitions' diff --git a/packages/editor/e2e-tests/__tests__/undo-redo-collaboration.feature b/packages/editor/e2e-tests/__tests__/undo-redo-collaboration.feature new file mode 100644 index 00000000..1efb1ff4 --- /dev/null +++ b/packages/editor/e2e-tests/__tests__/undo-redo-collaboration.feature @@ -0,0 +1,46 @@ +Feature: Undo/Redo Collaboration + + Background: + Given two editors + And a global keymap + + Scenario: Undoing local annotation added before remote annotation + Given the text "foobar" + And a "comment" "c1" around "foo" + And a "link" "l1" around "bar" by editor B + When undo is performed + Then the text is "foo,bar" + And "foo" has no marks + And "bar" has marks "l1" + + Scenario: Undoing local annotation added after remote annotation + Given the text "foobar" + And a "link" "l1" around "bar" by editor B + And a "comment" "c1" around "foo" + When undo is performed + Then the text is "foo,bar" + And "foo" has no marks + And "bar" has marks "l1" + + Scenario: Undoing local same-type annotation added before remote annotation + Given the text "foobar" + And a "comment" "c1" around "foo" + And a "comment" "c2" around "bar" by editor B + When undo is performed + Then the text is "foo,bar" + And "foo" has no marks + And "bar" has marks "c2" + + # Currently fails + @skip + Scenario: Undoing and redoing local annotation before remote annotation + Given the text "foobar" + And a "comment" "c1" around "foo" + And a "comment" "c2" around "bar" by editor B + When undo is performed + Then the text is "foo,bar" + And "foo" has no marks + And "bar" has marks "c2" + When redo is performed + Then "foo" has marks "c1" + And "bar" has marks "c2" diff --git a/packages/editor/e2e-tests/__tests__/undo-redo-collaboration.test.ts b/packages/editor/e2e-tests/__tests__/undo-redo-collaboration.test.ts new file mode 100644 index 00000000..4625ab19 --- /dev/null +++ b/packages/editor/e2e-tests/__tests__/undo-redo-collaboration.test.ts @@ -0,0 +1,11 @@ +/** @jest-environment ./setup/collaborative.jest.env.ts */ + +import {Feature} from '@sanity/gherkin-driver/jest' +import {parameterTypes, stepDefinitions} from './gherkin-step-definitions' +import undoRedoCollaboration from './undo-redo-collaboration.feature' + +Feature({ + featureText: undoRedoCollaboration, + stepDefinitions, + parameterTypes, +}) diff --git a/packages/editor/e2e-tests/__tests__/undo-redo.feature b/packages/editor/e2e-tests/__tests__/undo-redo.feature index 0ab8cc1a..0c67f975 100644 --- a/packages/editor/e2e-tests/__tests__/undo-redo.feature +++ b/packages/editor/e2e-tests/__tests__/undo-redo.feature @@ -1,7 +1,7 @@ Feature: Undo/Redo Background: - Given two editors + Given one editor And a global keymap Scenario: Undoing annotation @@ -43,47 +43,6 @@ Feature: Undo/Redo Then the text is "foo" And "foo" has marks "c1" - Scenario: Undoing local annotation added before remote annotation - Given the text "foobar" - And a "comment" "c1" around "foo" - And a "link" "l1" around "bar" by editor B - When undo is performed - Then the text is "foo,bar" - And "foo" has no marks - And "bar" has marks "l1" - - Scenario: Undoing local annotation added after remote annotation - Given the text "foobar" - And a "link" "l1" around "bar" by editor B - And a "comment" "c1" around "foo" - When undo is performed - Then the text is "foo,bar" - And "foo" has no marks - And "bar" has marks "l1" - - Scenario: Undoing local same-type annotation added before remote annotation - Given the text "foobar" - And a "comment" "c1" around "foo" - And a "comment" "c2" around "bar" by editor B - When undo is performed - Then the text is "foo,bar" - And "foo" has no marks - And "bar" has marks "c2" - - # Currently fails - @skip - Scenario: Undoing and redoing local annotation before remote annotation - Given the text "foobar" - And a "comment" "c1" around "foo" - And a "comment" "c2" around "bar" by editor B - When undo is performed - Then the text is "foo,bar" - And "foo" has no marks - And "bar" has marks "c2" - When redo is performed - Then "foo" has marks "c1" - And "bar" has marks "c2" - Scenario: Undoing and redoing inserting text after annotated text Given the text "foo" And a "comment" "c1" around "foo" diff --git a/packages/editor/e2e-tests/__tests__/undo-redo.test.ts b/packages/editor/e2e-tests/__tests__/undo-redo.test.ts index 8c704a73..2b0e8ddd 100644 --- a/packages/editor/e2e-tests/__tests__/undo-redo.test.ts +++ b/packages/editor/e2e-tests/__tests__/undo-redo.test.ts @@ -1,4 +1,4 @@ -/** @jest-environment ./setup/collaborative.jest.env.ts */ +/** @jest-environment ./setup/jest.env.ts */ import {Feature} from '@sanity/gherkin-driver/jest' import {parameterTypes, stepDefinitions} from './gherkin-step-definitions' diff --git a/packages/editor/e2e-tests/setup/collaborative.jest.env.ts b/packages/editor/e2e-tests/setup/collaborative.jest.env.ts index 130ec119..4bc79b2b 100644 --- a/packages/editor/e2e-tests/setup/collaborative.jest.env.ts +++ b/packages/editor/e2e-tests/setup/collaborative.jest.env.ts @@ -3,15 +3,12 @@ import { chromium, type Browser, type BrowserContext, - type ElementHandle, type Page, } from '@playwright/test' import type {PortableTextBlock} from '@sanity/types' import NodeEnvironment from 'jest-environment-node' -import {isEqual} from 'lodash' import ipc from 'node-ipc' -import type {EditorSelection} from '../../src' -import {normalizeSelection} from '../../src/utils/selection' +import {getPageEditor} from './get-page-editor' ipc.config.id = 'collaborative-jest-environment-ipc-client' ipc.config.retry = 5000 @@ -180,292 +177,24 @@ export default class CollaborationEnvironment extends NodeEnvironment { .catch(() => Promise.resolve()) } + this.global.getEditor = () => { + return Promise.reject( + new Error( + 'Looks like you are trying to run the collaborative test env with just one editor.', + ), + ) + } + this.global.getEditors = () => Promise.all( [this._pageA!, this._pageB!].map(async (page, index) => { - const userAgent = await page.evaluate(() => navigator.userAgent) - const isMac = /Mac|iPod|iPhone|iPad/.test(userAgent) - const metaKey = isMac ? 'Meta' : 'Control' const editorId = `${['A', 'B'][index]}${testId}` - const [ - editableHandle, - selectionHandle, - valueHandle, - revIdHandle, - ]: (ElementHandle | null)[] = await Promise.all([ - page.waitForSelector('div[contentEditable="true"]'), - page.waitForSelector('#pte-selection'), - page.waitForSelector('#pte-value'), - page.waitForSelector('#pte-revId'), - ]) - - if ( - !editableHandle || - !selectionHandle || - !valueHandle || - !revIdHandle - ) { - throw new Error('Failed to find required editor elements') - } - - const addCommentButtonLocator = page.getByTestId('button-add-comment') - const removeCommentButtonLocator = page.getByTestId( - 'button-remove-comment', - ) - const toggleCommentButtonLocator = page.getByTestId( - 'button-toggle-comment', - ) - const toggleLinkButtonLocator = page.getByTestId('button-toggle-link') - const insertImageButtonLocator = page.getByTestId( - 'button-insert-image', - ) - const insertStockTickerButtonLocator = page.getByTestId( - 'button-insert-stock-ticker', - ) - - const waitForRevision = async ( - mutatingFunction?: () => Promise, - ) => { - if (mutatingFunction) { - const currentRevId = await revIdHandle.evaluate((node) => - node instanceof HTMLElement && node.innerText - ? JSON.parse(node.innerText)?.revId - : null, - ) - await mutatingFunction() - await page.waitForSelector( - `code[data-rev-id]:not([data-rev-id='${currentRevId}'])`, - { - timeout: REVISION_TIMEOUT_MS, - }, - ) - } - } - const getSelection = async (): Promise => { - const selection = await selectionHandle.evaluate((node) => - node instanceof HTMLElement && node.innerText - ? JSON.parse(node.innerText) - : null, - ) - return selection - } - const waitForNewSelection = async ( - selectionChangeFn: () => Promise, - ) => { - const oldSelection = await getSelection() - const dataVal = oldSelection ? JSON.stringify(oldSelection) : 'null' - await selectionChangeFn() - await page.waitForSelector( - `code[data-selection]:not([data-selection='${dataVal}'])`, - { - timeout: SELECTION_TIMEOUT_MS, - }, - ) - } - - const waitForSelection = async (selection: EditorSelection) => { - if (selection && typeof selection.backward === 'undefined') { - selection.backward = false - } - const value = await valueHandle.evaluate( - (node): PortableTextBlock[] | undefined => - node instanceof HTMLElement && node.innerText - ? JSON.parse(node.innerText) - : undefined, - ) - const normalized = normalizeSelection(selection, value) - const dataVal = JSON.stringify(normalized) - await page.waitForSelector(`code[data-selection='${dataVal}']`, { - timeout: SELECTION_TIMEOUT_MS, - }) - } - return { - testId, + return getPageEditor({ editorId, - insertText: async (text: string) => { - await editableHandle.focus() - await waitForRevision(async () => { - await editableHandle.evaluate( - (node, args) => { - node.dispatchEvent( - new InputEvent('beforeinput', { - bubbles: true, - cancelable: true, - inputType: 'insertText', - data: args[0], - }), - ) - }, - [text], - ) - }) - }, - type: async (text) => { - await waitForRevision(async () => { - await page.keyboard.type(text) - }) - }, - undo: async () => { - await waitForRevision(async () => { - await editableHandle.focus() - await page.keyboard.down(metaKey) - await page.keyboard.press('z') - await page.keyboard.up(metaKey) - }) - }, - redo: async () => { - await waitForRevision(async () => { - await editableHandle.focus() - await page.keyboard.down(metaKey) - await page.keyboard.press('y') - await page.keyboard.up(metaKey) - }) - }, - paste: async (string: string, type = 'text/plain') => { - // Write text to native clipboard - await page.evaluate( - async ({string: _string, type: _type}) => { - await navigator.clipboard.writeText('') // Clear first - const blob = new Blob([_string], {type: _type}) - const data = [new ClipboardItem({[_type]: blob})] - await navigator.clipboard.write(data) - }, - {string, type}, - ) - await waitForRevision(async () => { - // Simulate paste key command - await page.keyboard.down(metaKey) - await page.keyboard.press('v') - await page.keyboard.up(metaKey) - }) - }, - pressButton: async (buttonName, times) => { - await waitForRevision(() => { - if (buttonName === 'add-comment') { - return addCommentButtonLocator.click({clickCount: times}) - } - - if (buttonName === 'remove-comment') { - return removeCommentButtonLocator.click({clickCount: times}) - } - - if (buttonName === 'toggle-comment') { - return toggleCommentButtonLocator.click({clickCount: times}) - } - - if (buttonName === 'insert-image') { - return insertImageButtonLocator.click({clickCount: times}) - } - - if (buttonName === 'insert-stock-ticker') { - return insertStockTickerButtonLocator.click({ - clickCount: times, - }) - } - - return Promise.reject( - new Error(`Button ${buttonName} not accounted for`), - ) - }) - }, - pressKey: async ( - keyName: string, - times?: number, - intent?: 'navigation', - ) => { - const pressKey = async () => { - await editableHandle.press(keyName) - } - for (let i = 0; i < (times || 1); i++) { - // Value manipulation keys - if ( - keyName.length === 1 || - keyName === 'Backspace' || - keyName === 'Delete' || - keyName === 'Enter' - ) { - if (intent === 'navigation') { - await waitForNewSelection(pressKey) - } else { - await waitForRevision(async () => { - await pressKey() - }) - } - } else if ( - // Selection manipulation keys - [ - 'ArrowUp', - 'ArrowDown', - 'ArrowLeft', - 'ArrowRight', - 'PageUp', - 'PageDown', - 'Home', - 'End', - ].includes(keyName) - ) { - await waitForNewSelection(pressKey) - } else { - // Unknown keys, test needs should be covered by the above cases. - console.warn(`Key ${keyName} not accounted for`) - await pressKey() - } - } - }, - toggleAnnotation: async (annotation) => { - await waitForRevision(() => { - if (annotation === 'comment') { - return toggleCommentButtonLocator.click() - } - - if (annotation === 'link') { - return toggleLinkButtonLocator.click() - } - - return Promise.reject( - new Error(`Annotation ${annotation} not accounted for`), - ) - }) - }, - toggleDecoratorUsingKeyboard: async (decorator) => { - const selection = await selectionHandle.evaluate((node) => - node instanceof HTMLElement && node.innerText - ? JSON.parse(node.innerText) - : null, - ) - const hotkey = - decorator === 'strong' - ? 'b' - : decorator === 'em' - ? 'i' - : undefined - - const performShortcut = hotkey - ? async () => { - await page.keyboard.down(metaKey) - await page.keyboard.down(hotkey) - await page.keyboard.up(hotkey) - await page.keyboard.up(metaKey) - } - : () => - Promise.reject( - new Error(`Decorator ${decorator} not accounted for`), - ) - - if (selection && isEqual(selection.focus, selection.anchor)) { - return performShortcut() - } - - return waitForRevision(performShortcut) - }, - focus: async () => { - await editableHandle.focus() - }, - setSelection: async (selection: EditorSelection | null) => { - if (selection && typeof selection.backward === 'undefined') { - selection.backward = false - } + page, + testId, + onSelection: (selection) => { ipc.of.socketServer.emit( 'payload', JSON.stringify({ @@ -475,20 +204,10 @@ export default class CollaborationEnvironment extends NodeEnvironment { editorId, }), ) - await waitForSelection(selection) - }, - async getValue(): Promise { - const value = await valueHandle.evaluate( - (node): PortableTextBlock[] | undefined => - node instanceof HTMLElement && node.innerText - ? JSON.parse(node.innerText) - : undefined, - ) - return value }, - getSelection, - waitForRevision, - } + REVISION_TIMEOUT_MS, + SELECTION_TIMEOUT_MS, + }) }), ) diff --git a/packages/editor/e2e-tests/setup/get-page-editor.ts b/packages/editor/e2e-tests/setup/get-page-editor.ts new file mode 100644 index 00000000..72e29dd5 --- /dev/null +++ b/packages/editor/e2e-tests/setup/get-page-editor.ts @@ -0,0 +1,302 @@ +import type {ElementHandle, Page} from '@playwright/test' +import type {PortableTextBlock} from '@sanity/types' +import {isEqual} from 'lodash' +import type {EditorSelection} from '../../src' +import {normalizeSelection} from '../../src/utils/selection' +import type {Editor} from './globals.jest' + +export async function getPageEditor({ + page, + editorId, + testId, + onSelection, + REVISION_TIMEOUT_MS, + SELECTION_TIMEOUT_MS, +}: { + page: Page + editorId: string + testId: string + onSelection: (selection: EditorSelection) => void + REVISION_TIMEOUT_MS: number + SELECTION_TIMEOUT_MS: number +}): Promise { + const userAgent = await page.evaluate(() => navigator.userAgent) + const isMac = /Mac|iPod|iPhone|iPad/.test(userAgent) + const metaKey = isMac ? 'Meta' : 'Control' + const [ + editableHandle, + selectionHandle, + valueHandle, + revIdHandle, + ]: (ElementHandle | null)[] = await Promise.all([ + page.waitForSelector('div[contentEditable="true"]'), + page.waitForSelector('#pte-selection'), + page.waitForSelector('#pte-value'), + page.waitForSelector('#pte-revId'), + ]) + + if (!editableHandle || !selectionHandle || !valueHandle || !revIdHandle) { + throw new Error('Failed to find required editor elements') + } + + const addCommentButtonLocator = page.getByTestId('button-add-comment') + const removeCommentButtonLocator = page.getByTestId('button-remove-comment') + const toggleCommentButtonLocator = page.getByTestId('button-toggle-comment') + const toggleLinkButtonLocator = page.getByTestId('button-toggle-link') + const insertImageButtonLocator = page.getByTestId('button-insert-image') + const insertStockTickerButtonLocator = page.getByTestId( + 'button-insert-stock-ticker', + ) + + const waitForRevision = async (mutatingFunction?: () => Promise) => { + if (mutatingFunction) { + const currentRevId = await revIdHandle.evaluate((node) => + node instanceof HTMLElement && node.innerText + ? JSON.parse(node.innerText)?.revId + : null, + ) + await mutatingFunction() + await page.waitForSelector( + `code[data-rev-id]:not([data-rev-id='${currentRevId}'])`, + { + timeout: REVISION_TIMEOUT_MS, + }, + ) + } + } + + const getSelection = async (): Promise => { + const selection = await selectionHandle.evaluate((node) => + node instanceof HTMLElement && node.innerText + ? JSON.parse(node.innerText) + : null, + ) + return selection + } + const waitForNewSelection = async ( + selectionChangeFn: () => Promise, + ) => { + const oldSelection = await getSelection() + const dataVal = oldSelection ? JSON.stringify(oldSelection) : 'null' + await selectionChangeFn() + await page.waitForSelector( + `code[data-selection]:not([data-selection='${dataVal}'])`, + { + timeout: SELECTION_TIMEOUT_MS, + }, + ) + } + + const waitForSelection = async (selection: EditorSelection) => { + if (selection && typeof selection.backward === 'undefined') { + selection.backward = false + } + const value = await valueHandle.evaluate( + (node): PortableTextBlock[] | undefined => + node instanceof HTMLElement && node.innerText + ? JSON.parse(node.innerText) + : undefined, + ) + const normalized = normalizeSelection(selection, value) + const dataVal = JSON.stringify(normalized) + await page.waitForSelector(`code[data-selection='${dataVal}']`, { + timeout: SELECTION_TIMEOUT_MS, + }) + } + return { + testId, + editorId, + insertText: async (text: string) => { + await editableHandle.focus() + await waitForRevision(async () => { + await editableHandle.evaluate( + (node, args) => { + node.dispatchEvent( + new InputEvent('beforeinput', { + bubbles: true, + cancelable: true, + inputType: 'insertText', + data: args[0], + }), + ) + }, + [text], + ) + }) + }, + type: async (text) => { + await waitForRevision(async () => { + await page.keyboard.type(text) + }) + }, + undo: async () => { + await waitForRevision(async () => { + await editableHandle.focus() + await page.keyboard.down(metaKey) + await page.keyboard.press('z') + await page.keyboard.up(metaKey) + }) + }, + redo: async () => { + await waitForRevision(async () => { + await editableHandle.focus() + await page.keyboard.down(metaKey) + await page.keyboard.press('y') + await page.keyboard.up(metaKey) + }) + }, + paste: async (string: string, type = 'text/plain') => { + // Write text to native clipboard + await page.evaluate( + async ({string: _string, type: _type}) => { + await navigator.clipboard.writeText('') // Clear first + const blob = new Blob([_string], {type: _type}) + const data = [new ClipboardItem({[_type]: blob})] + await navigator.clipboard.write(data) + }, + {string, type}, + ) + await waitForRevision(async () => { + // Simulate paste key command + await page.keyboard.down(metaKey) + await page.keyboard.press('v') + await page.keyboard.up(metaKey) + }) + }, + pressButton: async (buttonName, times) => { + await waitForRevision(() => { + if (buttonName === 'add-comment') { + return addCommentButtonLocator.click({clickCount: times}) + } + + if (buttonName === 'remove-comment') { + return removeCommentButtonLocator.click({clickCount: times}) + } + + if (buttonName === 'toggle-comment') { + return toggleCommentButtonLocator.click({clickCount: times}) + } + + if (buttonName === 'insert-image') { + return insertImageButtonLocator.click({clickCount: times}) + } + + if (buttonName === 'insert-stock-ticker') { + return insertStockTickerButtonLocator.click({ + clickCount: times, + }) + } + + return Promise.reject( + new Error(`Button ${buttonName} not accounted for`), + ) + }) + }, + pressKey: async ( + keyName: string, + times?: number, + intent?: 'navigation', + ) => { + const pressKey = async () => { + await editableHandle.press(keyName) + } + for (let i = 0; i < (times || 1); i++) { + // Value manipulation keys + if ( + keyName.length === 1 || + keyName === 'Backspace' || + keyName === 'Delete' || + keyName === 'Enter' + ) { + if (intent === 'navigation') { + await waitForNewSelection(pressKey) + } else { + await waitForRevision(async () => { + await pressKey() + }) + } + } else if ( + // Selection manipulation keys + [ + 'ArrowUp', + 'ArrowDown', + 'ArrowLeft', + 'ArrowRight', + 'PageUp', + 'PageDown', + 'Home', + 'End', + ].includes(keyName) + ) { + await waitForNewSelection(pressKey) + } else { + // Unknown keys, test needs should be covered by the above cases. + console.warn(`Key ${keyName} not accounted for`) + await pressKey() + } + } + }, + toggleAnnotation: async (annotation) => { + await waitForRevision(() => { + if (annotation === 'comment') { + return toggleCommentButtonLocator.click() + } + + if (annotation === 'link') { + return toggleLinkButtonLocator.click() + } + + return Promise.reject( + new Error(`Annotation ${annotation} not accounted for`), + ) + }) + }, + toggleDecoratorUsingKeyboard: async (decorator) => { + const selection = await selectionHandle.evaluate((node) => + node instanceof HTMLElement && node.innerText + ? JSON.parse(node.innerText) + : null, + ) + const hotkey = + decorator === 'strong' ? 'b' : decorator === 'em' ? 'i' : undefined + + const performShortcut = hotkey + ? async () => { + await page.keyboard.down(metaKey) + await page.keyboard.down(hotkey) + await page.keyboard.up(hotkey) + await page.keyboard.up(metaKey) + } + : () => + Promise.reject( + new Error(`Decorator ${decorator} not accounted for`), + ) + + if (selection && isEqual(selection.focus, selection.anchor)) { + return performShortcut() + } + + return waitForRevision(performShortcut) + }, + focus: async () => { + await editableHandle.focus() + }, + setSelection: async (selection: EditorSelection | null) => { + if (selection && typeof selection.backward === 'undefined') { + selection.backward = false + } + onSelection(selection) + await waitForSelection(selection) + }, + async getValue(): Promise { + const value = await valueHandle.evaluate( + (node): PortableTextBlock[] | undefined => + node instanceof HTMLElement && node.innerText + ? JSON.parse(node.innerText) + : undefined, + ) + return value + }, + getSelection, + } +} diff --git a/packages/editor/e2e-tests/setup/globals.jest.ts b/packages/editor/e2e-tests/setup/globals.jest.ts index 3eb3afc0..456e6f7b 100644 --- a/packages/editor/e2e-tests/setup/globals.jest.ts +++ b/packages/editor/e2e-tests/setup/globals.jest.ts @@ -32,6 +32,7 @@ export type Editor = { } declare global { + function getEditor(): Promise function getEditors(): Promise function setDocumentValue( value: PortableTextBlock[] | undefined, diff --git a/packages/editor/e2e-tests/setup/jest.env.ts b/packages/editor/e2e-tests/setup/jest.env.ts new file mode 100644 index 00000000..5537d8ce --- /dev/null +++ b/packages/editor/e2e-tests/setup/jest.env.ts @@ -0,0 +1,170 @@ +import type {Circus} from '@jest/types' +import { + chromium, + type Browser, + type BrowserContext, + type Page, +} from '@playwright/test' +import type {PortableTextBlock} from '@sanity/types' +import NodeEnvironment from 'jest-environment-node' +import ipc from 'node-ipc' +import {getPageEditor} from './get-page-editor' + +ipc.config.id = 'jest-environment-ipc-client' +ipc.config.retry = 5000 +ipc.config.networkPort = 3002 +ipc.config.silent = true + +const WEB_SERVER_ROOT_URL = 'http://localhost:3000' + +// Forward debug info from the PTE in the browsers +// const DEBUG = 'sanity-pte:*' +const DEBUG = process.env.DEBUG || false + +// Wait this long for selections to appear in the browser +// This should be set high to support slower host systems. +const SELECTION_TIMEOUT_MS = 5000 + +// How long to wait for a new revision to come back to the client(s) when patched through the server. +// This should be set high to support slower host systems. +const REVISION_TIMEOUT_MS = 5000 + +export default class CollaborationEnvironment extends NodeEnvironment { + private _browser?: Browser + private _page?: Page + private _context?: BrowserContext + private _scenario?: string + + public async handleTestEvent(event: { + name: string + test?: Circus.TestEntry + }): Promise { + if (event.name === 'run_start') { + await this._setupInstance() + } + if (event.name === 'test_start') { + await this._createNewTestPage() + if (event.test?.name) { + this._scenario = event.test.name + } + } + if (event.name === 'run_finish') { + await this._destroyInstance() + } + } + + private async _setupInstance(): Promise { + ipc.connectToNet('socketServer') + this._browser = await chromium.launch() + const context = await this._browser.newContext() + await context.grantPermissions(['clipboard-read', 'clipboard-write']) + this._context = context + this._page = await this._context.newPage() + } + + private async _destroyInstance(): Promise { + await this._page?.close() + await this._browser?.close() + ipc.disconnect('socketServer') + } + + private async _createNewTestPage(): Promise { + if (!this._page) { + throw new Error('Page not initialized') + } + + // This will identify this test throughout the web environment + const testId = (Math.random() + 1).toString(36).slice(7) + + // Hook up page console and npm debug in the PTE + if (DEBUG) { + await this._page.addInitScript((filter: string) => { + window.localStorage.debug = filter + }, DEBUG) + this._page.on('console', (message) => + console.log( + `A:${message.type().slice(0, 3).toUpperCase()} ${message.text()}`, + ), + ) + } + this._page.on('pageerror', (error) => { + console.error( + `Editor crashed${this._scenario ? ` (${this._scenario})` : ''}`, + error, + ) + throw error + }) + + this.global.setDocumentValue = async ( + value: PortableTextBlock[] | undefined, + ): Promise => { + const revId = (Math.random() + 1).toString(36).slice(7) + ipc.of.socketServer.emit( + 'payload', + JSON.stringify({type: 'value', value, testId, revId}), + ) + await this._page?.waitForSelector(`code[data-rev-id="${revId}"]`, { + timeout: REVISION_TIMEOUT_MS, + }) + } + + this.global.waitForRevision = async () => { + const pageRevIdHandle = await this._page?.waitForSelector('#pte-revId') + const pageCurrentRevId = await pageRevIdHandle!.evaluate((node) => + node instanceof HTMLElement && node.innerText + ? JSON.parse(node.innerText)?.revId + : null, + ) + const pageRevIdChanged = () => + this._page?.waitForSelector( + `code[data-rev-id]:not([data-rev-id='${pageCurrentRevId}'])`, + { + timeout: REVISION_TIMEOUT_MS, + }, + ) + + return Promise.all([pageRevIdChanged()]) + .then(() => Promise.resolve()) + .catch(() => Promise.resolve()) + } + + this.global.getEditor = async () => { + const editorId = `editor${testId}` + + return getPageEditor({ + page: this._page!, + editorId, + testId, + onSelection: (selection) => { + ipc.of.socketServer.emit( + 'payload', + JSON.stringify({ + type: 'selection', + selection, + testId, + editorId, + }), + ) + }, + REVISION_TIMEOUT_MS, + SELECTION_TIMEOUT_MS, + }) + } + + this.global.getEditors = () => { + return Promise.reject( + new Error( + 'Looks like you are trying to run the non-collaborative test env with two editors.', + ), + ) + } + + // Open up the test document + await this._page?.goto( + `${WEB_SERVER_ROOT_URL}?editorId=editor${testId}&testId=${testId}`, + { + waitUntil: 'load', + }, + ) + } +}