diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..62c0ad8 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,24 @@ +name: Tests +on: [push, pull_request] + +jobs: + test: + name: Run Playwright Tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: latest + - name: Install dependencies + run: npm ci + - name: Install Playwright Browsers + run: npx playwright install --with-deps + - name: Run Playwright tests + run: npm run test + - uses: actions/upload-artifact@v4 + if: always() + with: + name: playwright-report + path: playwright-report/ + retention-days: 30 diff --git a/.gitignore b/.gitignore index 6dc486a..257b8a5 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,7 @@ dist dist-electron release .env +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ diff --git a/package-lock.json b/package-lock.json index 6087403..8500eb5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -34,8 +34,10 @@ "@electron/asar": "^3.2.2", "@lezer/generator": "^1.5.1", "@lezer/markdown": "^1.1.2", + "@playwright/test": "^1.40.1", "@replit/codemirror-lang-csharp": "^6.2.0", "@rollup/plugin-node-resolve": "^15.0.1", + "@types/node": "^20.10.5", "@vitejs/plugin-vue": "^4.0.0", "debounce": "^1.2.1", "electron": "^28.0.0", @@ -1210,6 +1212,21 @@ "node": ">= 10.0.0" } }, + "node_modules/@playwright/test": { + "version": "1.40.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.40.1.tgz", + "integrity": "sha512-EaaawMTOeEItCRvfmkI9v6rBkF1svM8wjl/YPRrg2N2Wmp+4qJYkWtJsbew1szfKKDm6fPLy4YAanBhIlf9dWw==", + "dev": true, + "dependencies": { + "playwright": "1.40.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/@replit/codemirror-lang-csharp": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/@replit/codemirror-lang-csharp/-/codemirror-lang-csharp-6.2.0.tgz", @@ -1563,9 +1580,9 @@ "dev": true }, "node_modules/@types/node": { - "version": "18.19.3", - "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.3.tgz", - "integrity": "sha512-k5fggr14DwAytoA/t8rPrIz++lXK7/DqckthCmoZOKNsEbJkId4Z//BqgApXBUGrGddrigYa1oqheo/7YmW4rg==", + "version": "20.10.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.10.5.tgz", + "integrity": "sha512-nNPsNE65wjMxEKI93yOP+NPGGBJz/PoN3kZsVLee0XMiJolxSekEVD8wRwBUBqkwc7UWop0edW50yrCQW4CyRw==", "dev": true, "dependencies": { "undici-types": "~5.26.4" @@ -3447,6 +3464,15 @@ "node": ">= 10.0.0" } }, + "node_modules/electron/node_modules/@types/node": { + "version": "18.19.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.3.tgz", + "integrity": "sha512-k5fggr14DwAytoA/t8rPrIz++lXK7/DqckthCmoZOKNsEbJkId4Z//BqgApXBUGrGddrigYa1oqheo/7YmW4rg==", + "dev": true, + "dependencies": { + "undici-types": "~5.26.4" + } + }, "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", @@ -4882,6 +4908,50 @@ "node": ">=8" } }, + "node_modules/playwright": { + "version": "1.40.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.40.1.tgz", + "integrity": "sha512-2eHI7IioIpQ0bS1Ovg/HszsN/XKNwEG1kbzSDDmADpclKc7CyqkHw7Mg2JCz/bbCxg25QUPcjksoMW7JcIFQmw==", + "dev": true, + "dependencies": { + "playwright-core": "1.40.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=16" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.40.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.40.1.tgz", + "integrity": "sha512-+hkOycxPiV534c4HhpfX6yrlawqVUzITRKwHAmYfmsVreltEl6fAZJ3DPfLMOODw0H3s1Itd6MDCWmP1fl/QvQ==", + "dev": true, + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/plist": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/plist/-/plist-3.1.0.tgz", diff --git a/package.json b/package.json index d352e1a..51867ff 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,9 @@ "preview": "vite preview", "build_grammar": "lezer-generator src/editor/lang-heynote/heynote.grammar -o src/editor/lang-heynote/parser.js", "webapp:dev": "vite webapp", - "webapp:build": "vite build webapp" + "webapp:build": "vite build webapp", + "test": "playwright test", + "test:ui": "playwright test --ui" }, "devDependencies": { "@codemirror/autocomplete": "^6.11.1", @@ -51,8 +53,10 @@ "@electron/asar": "^3.2.2", "@lezer/generator": "^1.5.1", "@lezer/markdown": "^1.1.2", + "@playwright/test": "^1.40.1", "@replit/codemirror-lang-csharp": "^6.2.0", "@rollup/plugin-node-resolve": "^15.0.1", + "@types/node": "^20.10.5", "@vitejs/plugin-vue": "^4.0.0", "debounce": "^1.2.1", "electron": "^28.0.0", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..22c3505 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,78 @@ +import { defineConfig, devices } from '@playwright/test'; + +/** + * Read environment variables from file. + * https://github.com/motdotla/dotenv + */ +// require('dotenv').config(); + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + 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: 'html', + /* 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://localhost:3000', + + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: 'on-first-retry', + }, + + /* Configure projects for major browsers */ + projects: [ + { + 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: 'npx vite --port=3000 webapp', + url: 'http://localhost:3000', + timeout: 10 * 1000, + reuseExistingServer: !process.env.CI, + }, +}); diff --git a/src/components/Editor.vue b/src/components/Editor.vue index 4072d1b..f65a80c 100644 --- a/src/components/Editor.vue +++ b/src/components/Editor.vue @@ -55,6 +55,7 @@ showLineNumberGutter: this.showLineNumberGutter, showFoldGutter: this.showFoldGutter, }) + window._heynote_editor = this.editor window.document.addEventListener("currenciesLoaded", this.onCurrenciesLoaded) }) // set up window close handler that will save the buffer and quit diff --git a/src/editor/annotation.js b/src/editor/annotation.js index 3b4ca75..e7f72f2 100644 --- a/src/editor/annotation.js +++ b/src/editor/annotation.js @@ -3,4 +3,5 @@ import { Annotation } from "@codemirror/state" export const heynoteEvent = Annotation.define() export const LANGUAGE_CHANGE = "heynote-change" export const CURRENCIES_LOADED = "heynote-currencies-loaded" +export const SET_CONTENT = "heynote-set-content" diff --git a/src/editor/block/block.js b/src/editor/block/block.js index 40b868a..4aa7322 100644 --- a/src/editor/block/block.js +++ b/src/editor/block/block.js @@ -13,7 +13,7 @@ import { emptyBlockSelected } from "./select-all.js"; // tracks the size of the first delimiter let firstBlockDelimiterSize -function getBlocks(state) { +export function getBlocks(state) { const blocks = []; const tree = ensureSyntaxTree(state, state.doc.length) if (tree) { diff --git a/src/editor/editor.js b/src/editor/editor.js index 6578c03..9a4a319 100644 --- a/src/editor/editor.js +++ b/src/editor/editor.js @@ -8,7 +8,8 @@ import { heynoteDark } from "./theme/dark.js" import { heynoteBase } from "./theme/base.js" import { customSetup } from "./setup.js" import { heynoteLang } from "./lang-heynote/heynote.js" -import { noteBlockExtension, blockLineNumbers } from "./block/block.js" +import { noteBlockExtension, blockLineNumbers, getBlocks } from "./block/block.js" +import { heynoteEvent, SET_CONTENT } from "./annotation.js"; import { changeCurrentBlockLanguage, triggerCurrenciesLoaded } from "./block/commands.js" import { formatBlockContent } from "./block/format-code.js" import { heynoteKeymap } from "./keymap.js" @@ -111,6 +112,25 @@ export class HeynoteEditor { return this.view.state.sliceDoc() } + setContent(content) { + this.view.dispatch({ + changes: { + from: 0, + to: this.view.state.doc.length, + insert: content, + }, + annotations: [heynoteEvent.of(SET_CONTENT)], + }) + this.view.dispatch({ + selection: {anchor: this.view.state.doc.length, head: this.view.state.doc.length}, + scrollIntoView: true, + }) + } + + getBlocks() { + return getBlocks(this.view.state) + } + focus() { this.view.focus() } diff --git a/tests/basic-editing.spec.js b/tests/basic-editing.spec.js new file mode 100644 index 0000000..797a6fd --- /dev/null +++ b/tests/basic-editing.spec.js @@ -0,0 +1,40 @@ +import { test, expect } from "@playwright/test"; +import { HeynotePage } from "./test-utils.js"; + +let heynotePage + +test.beforeEach(async ({ page }) => { + console.log("beforeEach") + heynotePage = new HeynotePage(page) + await heynotePage.goto() +}); + +test("enter text and create new block", async ({ page }) => { + expect((await heynotePage.getBlocks()).length).toBe(1) + await page.locator("body").pressSequentially("Hello World!") + await page.locator("body").press("Enter") + await page.locator("body").press(heynotePage.isMac ? "Meta+Enter" : "Control+Enter") + await page.waitForTimeout(100); + expect((await heynotePage.getBlocks()).length).toBe(2) + expect(await heynotePage.getBlockContent(0)).toBe("Hello World!\n") + expect(await heynotePage.getBlockContent(1)).toBe("") + + // check that visual block layers are created + expect(await page.locator("css=.heynote-blocks-layer > div").count()).toBe(2) +}) + +test("backspace", async ({ page }) => { + + await page.locator("body").pressSequentially("Hello World!") + for (let i=0; i<5; i++) { + await page.locator("body").press("Backspace") + } + expect(await heynotePage.getBlockContent(0)).toBe("Hello W") +}) + +test("first block is protected", async ({ page }) => { + const initialContent = await heynotePage.getContent() + await page.locator("body").press("Backspace") + expect(await heynotePage.getBlockContent(0)).toBe("") + expect(await heynotePage.getContent()).toBe(initialContent) +}) diff --git a/tests/markdown.spec.js b/tests/markdown.spec.js new file mode 100644 index 0000000..e83528d --- /dev/null +++ b/tests/markdown.spec.js @@ -0,0 +1,35 @@ +import { test, expect } from "@playwright/test"; +import { HeynotePage } from "./test-utils.js"; + +let heynotePage + +test.beforeEach(async ({ page }) => { + console.log("beforeEach") + heynotePage = new HeynotePage(page) + await heynotePage.goto() +}); + +test("test markdown mode", async ({ page }) => { + heynotePage.setContent(` +∞∞∞markdown +# Markdown! + +- [ ] todo +- [x] done +`) + //await page.locator("body").pressSequentially("test") + expect(await page.locator("css=.status .status-block.lang")).toHaveText("Markdown") +}) + +test("checkbox toggle", async ({ page }) => { + heynotePage.setContent(` +∞∞∞markdown +- [ ] todo +`) + const checkbox = await page.locator("css=.cm-content input[type=checkbox]") + expect(checkbox).toHaveCount(1) + await checkbox.click() + expect(await heynotePage.getBlockContent(0)).toBe("- [x] todo\n") + await checkbox.click() + expect(await heynotePage.getBlockContent(0)).toBe("- [ ] todo\n") +}) diff --git a/tests/test-utils.js b/tests/test-utils.js new file mode 100644 index 0000000..005694c --- /dev/null +++ b/tests/test-utils.js @@ -0,0 +1,44 @@ +import { test, expect } from '@playwright/test'; + +export function pageErrorGetter(page) { + let messages = []; + page.on('pageerror', (error) => { + messages.push(`[${error.name}] ${error.message}`); + }); + return () => messages; +} + + +export class HeynotePage { + constructor(page) { + this.page = page + this.getErrors = pageErrorGetter(page) + this.isMac = process.platform === "darwin" + } + + async goto() { + await this.page.goto("/") + await expect(this.page).toHaveTitle(/Heynote/) + expect(this.getErrors()).toStrictEqual([]) + } + + async getBlocks() { + return await this.page.evaluate(() => window._heynote_editor.getBlocks()) + } + + async getContent() { + return await this.page.evaluate(() => window._heynote_editor.getContent()) + } + + async setContent(content) { + await this.page.evaluate((content) => window._heynote_editor.setContent(content), content) + } + + async getBlockContent(blockIndex) { + const blocks = await this.getBlocks() + const content = await this.getContent() + expect(blocks.length).toBeGreaterThan(blockIndex) + const block = blocks[blockIndex] + return content.slice(block.content.from, block.content.to) + } +}