diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..1951ef3 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,41 @@ +name: Build & Test +on: + pull_request: + branches: [main] + types: [opened, synchronize, reopened, ready_for_review] + +jobs: + main: + if: ${{ !github.event.pull_request.draft }} + name: "Build & Test" + runs-on: ubuntu-latest + timeout-minutes: 5 + container: + image: mcr.microsoft.com/playwright:v1.59.1-noble + options: --user 1001:1001 # Run as non-root user for security + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install pnpm + uses: pnpm/action-setup@v4 + with: + version: latest + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '24' + cache: 'pnpm' + + - name: Install dependencies + run: pnpm i + + - name: Build + run: pnpm run build + + - name: Test + run: pnpm run test + env: + CI: true diff --git a/.gitignore b/.gitignore index 0245aad..d0f9ec9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ -coverage +coverage/ node_modules/ .idea/ .vscode/ .vs/ dist/ +playwright-report/ +test-results/ diff --git a/package.json b/package.json index 858c424..1aaa956 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ ], "scripts": { "build": "rimraf dist && rollup -c", + "test": "playwright test", "size-report": "node scripts/compare-package-sizes.js" }, "keywords": [ @@ -35,6 +36,7 @@ "vue": "^3.4.0" }, "devDependencies": { + "@playwright/test": "^1.59.1", "@ramstack/hotkey": "^1.2.1", "@rollup/plugin-node-resolve": "^16.0.3", "@rollup/plugin-terser": "^1.0.0", @@ -44,6 +46,7 @@ "rollup": "^4.60.1", "rollup-plugin-bundle-size": "^1.0.3", "terser": "^5.46.1", - "tslib": "^2.8.1" + "tslib": "^2.8.1", + "vue": "^3.4.0" } } diff --git a/playwright.config.js b/playwright.config.js new file mode 100644 index 0000000..b8f6c59 --- /dev/null +++ b/playwright.config.js @@ -0,0 +1,32 @@ +import { defineConfig, devices } from "@playwright/test"; + +export default defineConfig({ + testDir: "./tests", + 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 ? 2 : undefined, + reporter: [ + ["html", { open: "never", outputFolder: "playwright-report" }] + ], + use: { + trace: "on-first-retry", + }, + projects: [ + { + name: "chromium", + use: { + ...devices["Desktop Chrome"] + }, + }, + { + name: "firefox", + use: { + ...devices["Desktop Firefox"] + }, + } + ] +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index be52849..2c1891f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,11 +7,10 @@ settings: importers: .: - dependencies: - vue: - specifier: ^3.4.0 - version: 3.5.30(typescript@5.9.3) devDependencies: + '@playwright/test': + specifier: ^1.59.1 + version: 1.59.1 '@ramstack/hotkey': specifier: ^1.2.1 version: 1.2.1 @@ -42,6 +41,9 @@ importers: tslib: specifier: ^2.8.1 version: 2.8.1 + vue: + specifier: ^3.4.0 + version: 3.5.30(typescript@5.9.3) packages: @@ -78,6 +80,11 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@playwright/test@1.59.1': + resolution: {integrity: sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==} + engines: {node: '>=18'} + hasBin: true + '@ramstack/hotkey@1.2.1': resolution: {integrity: sha512-Sbe0lY6i3pkPS/guxX0yLDIme0Y3QC/oBrwvEkxbk6iXqQW1gygnOt+ia/kpziPyPS/GeEyIljbXMCi9lgTPIg==} @@ -350,6 +357,11 @@ packages: resolution: {integrity: sha512-UxKlfCRuCBxSXU4C6t9scbDyWZ4VlaFFdojKtzJuSkuOBQ5CNFum+zZXFwHjo+CxBC1t6zlYPgHIgFjL8ggoEQ==} engines: {node: '>=0.10.0'} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -430,6 +442,16 @@ packages: resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} engines: {node: '>=12'} + playwright-core@1.59.1: + resolution: {integrity: sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.59.1: + resolution: {integrity: sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==} + engines: {node: '>=18'} + hasBin: true + postcss@8.5.8: resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} engines: {node: ^10 || ^12 || >=14} @@ -542,6 +564,10 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@playwright/test@1.59.1': + dependencies: + playwright: 1.59.1 + '@ramstack/hotkey@1.2.1': {} '@rollup/plugin-node-resolve@16.0.3(rollup@4.60.1)': @@ -753,6 +779,9 @@ snapshots: escape-string-regexp: 1.0.5 object-assign: 4.1.1 + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -820,6 +849,14 @@ snapshots: picomatch@4.0.3: {} + playwright-core@1.59.1: {} + + playwright@1.59.1: + dependencies: + playwright-core: 1.59.1 + optionalDependencies: + fsevents: 2.3.2 + postcss@8.5.8: dependencies: nanoid: 3.3.11 diff --git a/tests/assets/index.html b/tests/assets/index.html new file mode 100644 index 0000000..eb604d1 --- /dev/null +++ b/tests/assets/index.html @@ -0,0 +1,35 @@ + + + + + + + + +
+ + + + + + diff --git a/tests/vue-hotkey.spec.js b/tests/vue-hotkey.spec.js new file mode 100644 index 0000000..be62c51 --- /dev/null +++ b/tests/vue-hotkey.spec.js @@ -0,0 +1,417 @@ +import process from "node:process"; +import { test, expect } from "@playwright/test"; + +test.beforeEach(async ({ page }) => { + await page.goto(`file://${process.cwd()}/tests/assets/index.html`); +}); + +async function mount(page, template, data = {}) { + await page.evaluate( + state => window.__hotkeyTest.mount(state), + { template, data } + ); +} + +test("should trigger when Ctrl+K is pressed on element", async ({ page }) => { + await mount(page, ` +
+ + {{ count }} +
`, + { count: 0 } + ); + + await page.locator("#text").focus(); + await page.keyboard.press("Control+K"); + + await expect(page.locator("#count")).toHaveText("1"); +}); + +test("should trigger when Shift+K is pressed on element", async ({ page }) => { + await mount(page, ` +
+ + {{ count }} +
`, + { count: 0 } + ); + + await page.locator("#text").focus(); + await page.keyboard.press("Shift+K"); + + await expect(page.locator("#count")).toHaveText("1"); +}); + +test("should trigger when Ctrl+Alt+Shift+K is pressed", async ({ page }) => { + await mount(page, ` +
+ + {{ count }} +
`, + { count: 0 } + ); + + await page.locator("#text").focus(); + await page.keyboard.press("Control+Alt+Shift+K"); + + await expect(page.locator("#count")).toHaveText("1"); +}); + +test("should not trigger when additional modifier keys are pressed", async ({ page }) => { + await mount(page, ` +
+ + {{ count }} +
`, + { count: 0 } + ); + + await page.locator("#text").focus(); + await page.keyboard.press("Alt+Shift+K"); + + await expect(page.locator("#count")).toHaveText("0"); +}); + +test("should not trigger on non-matching keys", async ({ page }) => { + await mount(page, ` +
+
+ {{ count }} +
`, + { count: 0 } + ); + + await page.keyboard.press("Control+J"); + await page.keyboard.press("Control+Shift+K"); + await page.keyboard.press("K"); + + await expect(page.locator("#count")).toHaveText("0"); +}); + +test("should trigger on window when .window modifier is set", async ({ page }) => { + await mount(page, ` +
+ +
+ {{ count }} +
`, + { count: 0 } + ); + + await page.locator("#other").focus(); + await page.keyboard.press("Control+K"); + + await expect(page.locator("#count")).toHaveText("1"); +}); + +test("should trigger on document when .document modifier is set", async ({ page }) => { + await mount(page, ` +
+ +
+ {{ count }} +
`, + { count: 0 } + ); + + await page.locator("#other").focus(); + await page.keyboard.press("Control+K"); + + await expect(page.locator("#count")).toHaveText("1"); +}); + +test("should trigger only once when .once modifier is set", async ({ page }) => { + await mount(page, ` +
+ + {{ count }} +
`, + { count: 0 } + ); + + await page.locator("#text").focus(); + await page.keyboard.press("Control+K"); + await page.keyboard.press("Control+K"); + + await expect(page.locator("#count")).toHaveText("1"); +}); + +test("should trigger multiple times without .once modifier", async ({ page }) => { + await mount(page, ` +
+ + {{ count }} +
`, + { count: 0 } + ); + + await page.locator("#text").focus(); + await page.keyboard.press("Control+K"); + await page.keyboard.press("Control+K"); + + await expect(page.locator("#count")).toHaveText("2"); +}); + +test("should trigger on keydown by default", async ({ page }) => { + await mount(page, ` +
+ + {{ type }} +
`, + { type: "" } + ); + + await page.locator("#text").focus(); + await page.keyboard.press("Control+K"); + + await expect(page.locator("#type")).toHaveText("keydown"); +}); + +test("should trigger on keyup when arg is 'keyup'", async ({ page }) => { + await mount(page, ` +
+ + {{ type }} +
`, + { type: "" } + ); + + await page.locator("#text").focus(); + await page.keyboard.down("Control"); + await page.keyboard.down("K"); + + await expect(page.locator("#type")).toHaveText(""); + + await page.keyboard.up("K"); + + await expect(page.locator("#type")).toHaveText("keyup"); +}); + +test("should register multiple hotkeys from multiple modifiers", async ({ page }) => { + await mount(page, ` +
+ + {{ hotkey }} +
`, + { hotkey: "" } + ); + + await page.locator("#text").focus(); + await page.keyboard.press("Control+S"); + await expect(page.locator("#hotkey")).toHaveText("ctrl+s"); + + await page.keyboard.press("Shift+S"); + await expect(page.locator("#hotkey")).toHaveText("shift+s"); +}); + +test("should set hotkey property on the event", async ({ page }) => { + await mount(page, ` +
+ + {{ hotkey }} +
`, + { hotkey: "" } + ); + + await page.locator("#text").focus(); + await page.keyboard.press("Control+K"); + + await expect(page.locator("#hotkey")).toHaveText("ctrl+k"); +}); + +test("should call preventDefault when .prevent modifier is set", async ({ page }) => { + await mount(page, ` +
+ + {{ prevented }} +
`, + { prevented: false } + ); + + await page.locator("#text").focus(); + await page.keyboard.press("Control+K"); + + await expect(page.locator("#prevented")).toHaveText("true"); +}); + +test("should not call preventDefault without .prevent modifier", async ({ page }) => { + await mount(page, ` +
+ + {{ prevented }} +
`, + { prevented: false } + ); + + await page.locator("#text").focus(); + await page.keyboard.press("Control+K"); + + await expect(page.locator("#prevented")).toHaveText("false"); +}); + +test("should call stopPropagation when .stop modifier is set", async ({ page }) => { + await mount(page, ` +
+
+ +
+ {{ outer }} + {{ inner }} +
`, + { + outer: 0, + inner: 0 + } + ); + + await page.locator("#btn").focus(); + await page.keyboard.press("Control+K"); + + await expect(page.locator("#inner")).toHaveText("1"); + await expect(page.locator("#outer")).toHaveText("0"); +}); + +test("should propagate event without .stop modifier", async ({ page }) => { + await mount(page, ` +
+
+ +
+ {{ outer }} + {{ inner }} +
`, + { + outer: 0, + inner: 0 + } + ); + + await page.locator("#btn").focus(); + await page.keyboard.press("Control+K"); + + await expect(page.locator("#inner")).toHaveText("1"); + await expect(page.locator("#outer")).toHaveText("1"); +}); + +test("should not trigger on untrusted events when .trusted modifier is set", async ({ page }) => { + await mount(page, ` +
+ + {{ count }} +
`, + { count: 0 } + ); + + await page.locator("#text").evaluate(el => { + el.dispatchEvent( + new KeyboardEvent("keydown", { + key: "k", + code: "KeyK", + ctrlKey: true, + bubbles: true + }) + ); + }); + + await expect(page.locator("#count")).toHaveText("0"); +}); + +test("should trigger on untrusted events without .trusted modifier", async ({ page }) => { + await mount(page, ` +
+ + {{ count }} +
`, + { count: 0 } + ); + + await page.locator("#text").evaluate(el => { + el.dispatchEvent( + new KeyboardEvent("keydown", { + key: "k", + code: "KeyK", + ctrlKey: true, + bubbles: true + }) + ); + }); + + await expect(page.locator("#count")).toHaveText("1"); +}); + +test("should clean up listener when element is removed", async ({ page }) => { + await mount(page, ` +
+
+ + {{ count }} +
`, + { + count: 0, + visible: true + } + ); + + await page.keyboard.press("Control+K"); + await expect(page.locator("#count")).toHaveText("1"); + + const target = page.locator("#target"); + await expect(target).toBeAttached(); + + await page.locator("#remove").click(); + await target.waitFor({ state: "detached" }); + + await page.keyboard.press("Control+K"); + await expect(page.locator("#count")).toHaveText("1"); +}); + +test("should not trigger on elements with [data-hotkey-ignore]", async ({ page }) => { + await mount(page, ` +
+ + {{ count }} +
`, + { count: 0 } + ); + + await page.locator("#ignored").focus(); + await page.keyboard.press("Control+K"); + + await expect(page.locator("#count")).toHaveText("0"); +}); + +test("should not trigger when parent has [data-hotkey-ignore]", async ({ page }) => { + await mount(page, ` +
+
+
+
+ +
+
+
+ {{ count }} +
`, + { count: 0 } + ); + + await page.locator("#ignored-via-parent").focus(); + await page.keyboard.press("Control+K"); + + await expect(page.locator("#count")).toHaveText("0"); +}); + +test("should not throw when directive has no handler", async ({ page }) => { + const errors = []; + page.on("pageerror", e => errors.push(e)); + + await mount(page, ` +
+
+
+ `); + + await page.keyboard.press("Control+K"); + + expect(errors).toHaveLength(0); +}); diff --git a/tsconfig.json b/tsconfig.json index 81629e3..a56628e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,13 +1,13 @@ -{ - "compilerOptions": { - "target": "ES2020", - "module": "ES2020", - "declaration": true, - "outDir": "./dist", - "declarationDir": "./dist/types", - "moduleResolution": "Bundler" - }, - "vueCompilerOptions": { - "target": 3.3 - } -} +{ + "compilerOptions": { + "target": "es2022", + "module": "es2020", + "declaration": true, + "outDir": "./dist", + "declarationDir": "./dist/types", + "moduleResolution": "Bundler" + }, + "include": [ + "src/**/*.ts" + ] +}