diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6bc7935..e3092cd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,7 +3,7 @@ name: CI on: push: branches: - - '**' + - "**" jobs: build: @@ -23,9 +23,16 @@ jobs: - name: Install Playwright Browsers run: npx playwright install chromium - + # note: we need to build before running tests because it's a chrome extension - name: Test run: | yarn build - yarn test:e2e \ No newline at end of file + yarn test:e2e + + - uses: actions/upload-artifact@v4 + if: ${{ !cancelled() }} + with: + name: playwright-report + path: playwright-report/ + retention-days: 30 diff --git a/playwright.config.ts b/playwright.config.ts index 06bbed5..f5cffd8 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,4 +1,4 @@ -import { defineConfig, devices } from '@playwright/test'; +import { defineConfig, devices } from "@playwright/test"; /** * Read environment variables from file. @@ -12,68 +12,27 @@ import { defineConfig, devices } from '@playwright/test'; * See https://playwright.dev/docs/test-configuration. */ export default defineConfig({ - testDir: 'tests', + testDir: "tests", /* Run tests in files in parallel */ fullyParallel: true, /* 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, - /* Opt out of parallel tests on CI. */ workers: process.env.CI ? 1 : undefined, /* Reporter to use. See https://playwright.dev/docs/test-reporters */ - reporter: 'list', + reporter: [["html", { open: "never" }], ["list"]], /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ use: { - /* Base URL to use in actions like `await page.goto('/')`. */ - // baseURL: 'http://127.0.0.1:3000', - /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ - trace: 'on-first-retry', + trace: "on-first-retry", }, /* Configure projects for major browsers */ projects: [ { - name: 'chromium', - use: { ...devices['Desktop Chrome'] }, + name: "chromium", + use: { ...devices["Desktop Chrome"] }, }, - - // { - // name: 'firefox', - // use: { ...devices['Desktop Firefox'] }, - // }, - - // { - // name: 'webkit', - // use: { ...devices['Desktop Safari'] }, - // }, - - /* Test against mobile viewports. */ - // { - // name: 'Mobile Chrome', - // use: { ...devices['Pixel 5'] }, - // }, - // { - // name: 'Mobile Safari', - // use: { ...devices['iPhone 12'] }, - // }, - - /* Test against branded browsers. */ - // { - // name: 'Microsoft Edge', - // use: { ...devices['Desktop Edge'], channel: 'msedge' }, - // }, - // { - // name: 'Google Chrome', - // use: { ...devices['Desktop Chrome'], channel: 'chrome' }, - // }, ], - - /* Run your local dev server before starting the tests */ - // webServer: { - // command: 'npm run start', - // url: 'http://127.0.0.1:3000', - // reuseExistingServer: !process.env.CI, - // }, }); diff --git a/src/lib/extensions/CommitUndo.ts b/src/lib/extensions/CommitUndo.ts new file mode 100644 index 0000000..a9b0bb4 --- /dev/null +++ b/src/lib/extensions/CommitUndo.ts @@ -0,0 +1,26 @@ +import { Editor, Extension } from "@tiptap/core"; +import { yUndoPluginKey } from "y-prosemirror"; +import { UndoManager } from "yjs"; + +const commitUndo = (editor: Editor) => { + const state = editor.state; + const undoManager: UndoManager = yUndoPluginKey.getState(state).undoManager; + undoManager.stopCapturing(); +}; + +export const CommitUndo = Extension.create({ + name: "commitOnEnter", + + addKeyboardShortcuts() { + return { + Enter: ({ editor }) => { + commitUndo(editor); + return false; + }, + Space: ({ editor }) => { + commitUndo(editor); + return false; + }, + }; + }, +}); diff --git a/src/lib/extensions.ts b/src/lib/extensions/index.ts similarity index 90% rename from src/lib/extensions.ts rename to src/lib/extensions/index.ts index 89fdb89..9a3650a 100644 --- a/src/lib/extensions.ts +++ b/src/lib/extensions/index.ts @@ -10,7 +10,8 @@ import { nodePasteRule, type PasteRuleFinder } from "@tiptap/core"; import * as Y from "yjs"; import Collaboration from "@tiptap/extension-collaboration"; import Underline from "@tiptap/extension-underline"; -import TextAlign from '@tiptap/extension-text-align' +import TextAlign from "@tiptap/extension-text-align"; +import { CommitUndo } from "./CommitUndo"; export const loadExtensions = (doc: Y.Doc) => { const ImageFinder: PasteRuleFinder = /data:image\//g; @@ -35,6 +36,7 @@ export const loadExtensions = (doc: Y.Doc) => { }); const lowlight = createLowlight(common); + console.log(doc); // define your extension array const extensions = [ @@ -58,8 +60,9 @@ export const loadExtensions = (doc: Y.Doc) => { document: doc, // Configure Y.Doc for collaboration }), TextAlign.configure({ - types: ['heading', 'paragraph'] - }) + types: ["heading", "paragraph"], + }), + CommitUndo, ]; return extensions; diff --git a/tests/commit.spec.ts b/tests/commit.spec.ts new file mode 100644 index 0000000..d44f71b --- /dev/null +++ b/tests/commit.spec.ts @@ -0,0 +1,62 @@ +import { test, expect, getPreparedTab, equals } from "./fixtures"; +import { loremIpsum } from "lorem-ipsum"; + +// skipped because of https://github.com/microsoft/playwright/issues/33832 +test.skip("Ctrl+Z should only undo the last line after Enter", async ({ + context, +}) => { + const tab = await getPreparedTab(context); + const input = await tab.getByRole("textbox"); + // remove all content + await input.fill(""); + + // Generate two sentences of text + const firstLine = loremIpsum({ count: 1, units: "words" }); + const secondLine = loremIpsum({ count: 1, units: "words" }); + + // Type the first line and press Enter + await input.pressSequentially(firstLine, { delay: 10 }); + await tab.keyboard.press("Enter"); + + // Type the second line + await input.pressSequentially(secondLine, { delay: 10 }); + + // Verify both lines are present in the textbox + await equals(input, `${firstLine}\n\n${secondLine}`); + + // Undo the last line by pressing Ctrl+Z + await tab.keyboard.press('ControlOrMeta+Z'); + + // Verify the second line was removed + await equals(input, firstLine); +}); + +// skipped because of https://github.com/microsoft/playwright/issues/33832 +test.skip("Ctrl+Z should only undo the last word after Space", async ({ + context, +}) => { + const tab = await getPreparedTab(context); + const input = await tab.getByRole("textbox"); + // remove all content + await input.fill(""); + + // Generate two words of text + const firstWord = loremIpsum({ count: 1, units: "words" }); + const secondWord = loremIpsum({ count: 1, units: "words" }); + + // Type the first word and press Space + await input.pressSequentially(firstWord, { delay: 10 }); + await tab.keyboard.press("Space"); + + // Type the second word + await input.pressSequentially(secondWord, { delay: 10 }); + + // Verify both words are present in the textbox + await equals(input, `${firstWord} ${secondWord}`); + + // Undo the last word by pressing Ctrl+Z + await tab.keyboard.press('ControlOrMeta+Z'); + + // Verify the second word was removed + await equals(input, firstWord); +}); diff --git a/tests/fixtures.ts b/tests/fixtures.ts index c865d17..9a3aee7 100644 --- a/tests/fixtures.ts +++ b/tests/fixtures.ts @@ -46,3 +46,35 @@ export const test = base.extend({ }); export { expect } from "@playwright/test"; + +import { Locator } from '@playwright/test'; + +/** + * Verifies that the Tiptap editor content contains the expected text. + * + * @param editorLocator - The Playwright locator for the editor element. + * @param expectedText - The text to verify is contained within the editor. + * @throws Error if the expected text is not found in the editor content. + */ +export async function contains(editorLocator: Locator, expectedText: string): Promise { + const content = await editorLocator.innerText(); + + if (!content || !content.includes(expectedText)) { + throw new Error(`Expected text "${expectedText}" not found in editor content: "${content || ''}"`); + } +} + +/** + * Verifies that the Tiptap editor content equals the expected text. + * + * @param editorLocator - The Playwright locator for the editor element. + * @param expectedText - The text to verify exactly matches the editor content. + * @throws Error if the editor content does not exactly match the expected text. + */ +export async function equals(editorLocator: Locator, expectedText: string): Promise { + const content = await editorLocator.innerText(); + + if (content !== expectedText) { + throw new Error(`Editor content does not match. Expected: "${expectedText}", Found: "${content || ''}"`); + } +} diff --git a/tests/persistence.spec.ts b/tests/persistence.spec.ts index 2439a83..d53d41c 100644 --- a/tests/persistence.spec.ts +++ b/tests/persistence.spec.ts @@ -5,9 +5,10 @@ test("make sure that text is persistent between page refreshes", async ({ newtab, }) => { const testText = loremIpsum({ count: 1, units: "sentences" }); - await newtab.keyboard.type(testText); + await newtab.keyboard.type(testText, {delay: 50}); - // refresh the page + // refresh the page after 100ms + await new Promise((resolve) => setTimeout(resolve, 100)); await newtab.reload(); // wait for the text to sync