diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6a59f2f..f7d9430 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,3 +48,33 @@ jobs: - name: Check formatting of everything (prettier) run: | npm run fmt:check + + e2e-test: + name: E2E Tests (Playwright) + timeout-minutes: 60 + # run only if triggered by push on a branch or by a PR event for a PR which is not a draft + if: ${{ !github.event.pull_request || github.event.pull_request.draft == false }} + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + + - name: Install Node.js + uses: actions/setup-node@v4 + with: + node-version-file: ".nvmrc" + cache: npm + + - name: Install dependencies + run: | + npm ci + + - name: Run Playwright tests + run: | + npm run test:e2e + + - uses: actions/upload-artifact@v4 + if: ${{ !cancelled() }} + with: + name: playwright-report + path: playwright-report/ + retention-days: 30 diff --git a/.gitignore b/.gitignore index dfaa43c..f7f3594 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,9 @@ npm-debug.log* # typescript *.tsbuildinfo + +# Playwright +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ diff --git a/README.md b/README.md index 5be3ce6..5dc13b2 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ To install and set up the project, follow these steps: 1. Ensure you have Node.js v20 installed. You can download it from the [official Node.js website](https://nodejs.org/). 2. Clone the repository to your local machine. 3. Install the project dependencies using npm - `npm install`. +4. Setup Docker to run E2E tests via Playwright ([Docker Desktop](https://www.docker.com/products/docker-desktop/) or [Docker Engine](https://docs.docker.com/engine/install/)). This will install all the necessary packages and dependencies required to run the project. @@ -28,6 +29,7 @@ Open [http://localhost:5173](http://localhost:5173) with your browser to see the - `npm run start`: Starts the development server. - `npm run build`: Builds the app for production. +- `npm run test:e2e:update`: Runs all End-to-End tests and updates the screenshots/snapshots. ## Configuration diff --git a/e2e-tests/code-editing-and-ast-interaction.spec.ts b/e2e-tests/code-editing-and-ast-interaction.spec.ts new file mode 100644 index 0000000..b16609f --- /dev/null +++ b/e2e-tests/code-editing-and-ast-interaction.spec.ts @@ -0,0 +1,59 @@ +/** + * Tests for code editing functionality and AST tool interaction. + */ +import test, { expect } from "@playwright/test"; + +/** + * This test verifies that: + * - Users can edit code in the editor + * - The AST updates in response to code changes + * - ESQuery selectors correctly highlight matching code and AST nodes + * - AST node expansion functionality works properly + */ +test(`should change code, then highlight code and AST nodes matching ESQuery selector`, async ({ + page, +}) => { + await page.goto("/"); + + // focus code editor textbox + await page + .getByRole("region", { name: "Code Editor Panel" }) + .getByRole("textbox") + .nth(1) + .click(); + + // delete the default code + await page.keyboard.press("Control+KeyA"); + await page.keyboard.press("Backspace"); + + // add new code + await page.keyboard.type("console.log('Hello, World!');"); + + // add an ESQuery selector + await page.getByRole("textbox", { name: "ESQuery Selector" }).click(); + await page.keyboard.type("CallExpression"); + + // wait for the debounced update of the AST to happen + await expect( + page + .getByRole("listitem") + .filter({ hasText: "end" }) + .filter({ hasText: "29" }), + ).toBeVisible(); + + // expand AST nodes for ExpressionStatement and CallExpression + await page + .getByRole("region", { name: "Program" }) + .getByRole("listitem") + .filter({ hasText: "Array" }) + .getByRole("button", { name: "Toggle Property" }) + .click(); + await page.getByRole("button", { name: "ExpressionStatement" }).click(); + await page + .getByRole("region", { name: "ExpressionStatement" }) + .getByLabel("Toggle Property") + .click(); + + // screenshot + await expect(page).toHaveScreenshot(); +}); diff --git a/e2e-tests/code-editing-and-ast-interaction.spec.ts-snapshots/should-change-code-then-highlight-code-and-AST-nodes-matching-ESQuery-selector-1-chromium-docker.png b/e2e-tests/code-editing-and-ast-interaction.spec.ts-snapshots/should-change-code-then-highlight-code-and-AST-nodes-matching-ESQuery-selector-1-chromium-docker.png new file mode 100644 index 0000000..1a24950 Binary files /dev/null and b/e2e-tests/code-editing-and-ast-interaction.spec.ts-snapshots/should-change-code-then-highlight-code-and-AST-nodes-matching-ESQuery-selector-1-chromium-docker.png differ diff --git a/e2e-tests/code-editing-and-ast-interaction.spec.ts-snapshots/should-change-code-then-highlight-code-and-AST-nodes-matching-ESQuery-selector-1-firefox-docker.png b/e2e-tests/code-editing-and-ast-interaction.spec.ts-snapshots/should-change-code-then-highlight-code-and-AST-nodes-matching-ESQuery-selector-1-firefox-docker.png new file mode 100644 index 0000000..bed35bc Binary files /dev/null and b/e2e-tests/code-editing-and-ast-interaction.spec.ts-snapshots/should-change-code-then-highlight-code-and-AST-nodes-matching-ESQuery-selector-1-firefox-docker.png differ diff --git a/e2e-tests/code-editing-and-ast-interaction.spec.ts-snapshots/should-change-code-then-highlight-code-and-AST-nodes-matching-ESQuery-selector-1-webkit-docker.png b/e2e-tests/code-editing-and-ast-interaction.spec.ts-snapshots/should-change-code-then-highlight-code-and-AST-nodes-matching-ESQuery-selector-1-webkit-docker.png new file mode 100644 index 0000000..aa0fc84 Binary files /dev/null and b/e2e-tests/code-editing-and-ast-interaction.spec.ts-snapshots/should-change-code-then-highlight-code-and-AST-nodes-matching-ESQuery-selector-1-webkit-docker.png differ diff --git a/e2e-tests/light-dark-theme.spec.ts b/e2e-tests/light-dark-theme.spec.ts new file mode 100644 index 0000000..0bcbac5 --- /dev/null +++ b/e2e-tests/light-dark-theme.spec.ts @@ -0,0 +1,24 @@ +/** + * Tests for theme switching functionality. + */ + +import { test, expect } from "@playwright/test"; + +/** + * This test verifies that: + * - The application shows light theme by default + * - Users can toggle between light and dark themes + * - Theme changes are visually reflected in the UI + */ +test("should show light theme by default and switch to dark theme", async ({ + page, +}) => { + await page.goto("/"); + + await expect(page).toHaveScreenshot("light-theme.png"); + + await page.getByRole("button", { name: "Toggle theme" }).click(); + await page.getByRole("menuitem", { name: "Dark" }).click(); + + await expect(page).toHaveScreenshot("dark-theme.png"); +}); diff --git a/e2e-tests/light-dark-theme.spec.ts-snapshots/dark-theme-chromium-docker.png b/e2e-tests/light-dark-theme.spec.ts-snapshots/dark-theme-chromium-docker.png new file mode 100644 index 0000000..6802434 Binary files /dev/null and b/e2e-tests/light-dark-theme.spec.ts-snapshots/dark-theme-chromium-docker.png differ diff --git a/e2e-tests/light-dark-theme.spec.ts-snapshots/dark-theme-firefox-docker.png b/e2e-tests/light-dark-theme.spec.ts-snapshots/dark-theme-firefox-docker.png new file mode 100644 index 0000000..4c2d2b0 Binary files /dev/null and b/e2e-tests/light-dark-theme.spec.ts-snapshots/dark-theme-firefox-docker.png differ diff --git a/e2e-tests/light-dark-theme.spec.ts-snapshots/dark-theme-webkit-docker.png b/e2e-tests/light-dark-theme.spec.ts-snapshots/dark-theme-webkit-docker.png new file mode 100644 index 0000000..40a276e Binary files /dev/null and b/e2e-tests/light-dark-theme.spec.ts-snapshots/dark-theme-webkit-docker.png differ diff --git a/e2e-tests/light-dark-theme.spec.ts-snapshots/light-theme-chromium-docker.png b/e2e-tests/light-dark-theme.spec.ts-snapshots/light-theme-chromium-docker.png new file mode 100644 index 0000000..069d8e6 Binary files /dev/null and b/e2e-tests/light-dark-theme.spec.ts-snapshots/light-theme-chromium-docker.png differ diff --git a/e2e-tests/light-dark-theme.spec.ts-snapshots/light-theme-firefox-docker.png b/e2e-tests/light-dark-theme.spec.ts-snapshots/light-theme-firefox-docker.png new file mode 100644 index 0000000..b0a3b74 Binary files /dev/null and b/e2e-tests/light-dark-theme.spec.ts-snapshots/light-theme-firefox-docker.png differ diff --git a/e2e-tests/light-dark-theme.spec.ts-snapshots/light-theme-webkit-docker.png b/e2e-tests/light-dark-theme.spec.ts-snapshots/light-theme-webkit-docker.png new file mode 100644 index 0000000..fcf9930 Binary files /dev/null and b/e2e-tests/light-dark-theme.spec.ts-snapshots/light-theme-webkit-docker.png differ diff --git a/e2e-tests/options.spec.ts b/e2e-tests/options.spec.ts new file mode 100644 index 0000000..3501981 --- /dev/null +++ b/e2e-tests/options.spec.ts @@ -0,0 +1,33 @@ +/** + * Tests for language selection and options panel functionality. + */ +import { test, expect } from "@playwright/test"; + +/** + * This test verifies that: + * - Users can open the language options popover + * - Users can switch between supported languages (JavaScript, JSON, Markdown, CSS) + * - For each language the entire page is correctly rendered + */ +test("should switch language and show options for each", async ({ page }) => { + await page.goto("/"); + + await page.getByRole("button", { name: "Language Options" }).click(); + + await expect(page).toHaveScreenshot("page-javascript.png"); + + await page.getByRole("combobox", { name: "Language" }).click(); + await page.getByRole("option", { name: "JSON JSON" }).click(); + + await expect(page).toHaveScreenshot("page-json.png"); + + await page.getByRole("combobox", { name: "Language" }).click(); + await page.getByRole("option", { name: "Markdown Markdown" }).click(); + + await expect(page).toHaveScreenshot("page-markdown.png"); + + await page.getByRole("combobox", { name: "Language" }).click(); + await page.getByRole("option", { name: "CSS CSS" }).click(); + + await expect(page).toHaveScreenshot("page-css.png"); +}); diff --git a/e2e-tests/options.spec.ts-snapshots/page-css-chromium-docker.png b/e2e-tests/options.spec.ts-snapshots/page-css-chromium-docker.png new file mode 100644 index 0000000..fc6e455 Binary files /dev/null and b/e2e-tests/options.spec.ts-snapshots/page-css-chromium-docker.png differ diff --git a/e2e-tests/options.spec.ts-snapshots/page-css-firefox-docker.png b/e2e-tests/options.spec.ts-snapshots/page-css-firefox-docker.png new file mode 100644 index 0000000..968b661 Binary files /dev/null and b/e2e-tests/options.spec.ts-snapshots/page-css-firefox-docker.png differ diff --git a/e2e-tests/options.spec.ts-snapshots/page-css-webkit-docker.png b/e2e-tests/options.spec.ts-snapshots/page-css-webkit-docker.png new file mode 100644 index 0000000..16c7165 Binary files /dev/null and b/e2e-tests/options.spec.ts-snapshots/page-css-webkit-docker.png differ diff --git a/e2e-tests/options.spec.ts-snapshots/page-javascript-chromium-docker.png b/e2e-tests/options.spec.ts-snapshots/page-javascript-chromium-docker.png new file mode 100644 index 0000000..f60cc37 Binary files /dev/null and b/e2e-tests/options.spec.ts-snapshots/page-javascript-chromium-docker.png differ diff --git a/e2e-tests/options.spec.ts-snapshots/page-javascript-firefox-docker.png b/e2e-tests/options.spec.ts-snapshots/page-javascript-firefox-docker.png new file mode 100644 index 0000000..3291958 Binary files /dev/null and b/e2e-tests/options.spec.ts-snapshots/page-javascript-firefox-docker.png differ diff --git a/e2e-tests/options.spec.ts-snapshots/page-javascript-webkit-docker.png b/e2e-tests/options.spec.ts-snapshots/page-javascript-webkit-docker.png new file mode 100644 index 0000000..6cfa077 Binary files /dev/null and b/e2e-tests/options.spec.ts-snapshots/page-javascript-webkit-docker.png differ diff --git a/e2e-tests/options.spec.ts-snapshots/page-json-chromium-docker.png b/e2e-tests/options.spec.ts-snapshots/page-json-chromium-docker.png new file mode 100644 index 0000000..8c36ef7 Binary files /dev/null and b/e2e-tests/options.spec.ts-snapshots/page-json-chromium-docker.png differ diff --git a/e2e-tests/options.spec.ts-snapshots/page-json-firefox-docker.png b/e2e-tests/options.spec.ts-snapshots/page-json-firefox-docker.png new file mode 100644 index 0000000..3973c21 Binary files /dev/null and b/e2e-tests/options.spec.ts-snapshots/page-json-firefox-docker.png differ diff --git a/e2e-tests/options.spec.ts-snapshots/page-json-webkit-docker.png b/e2e-tests/options.spec.ts-snapshots/page-json-webkit-docker.png new file mode 100644 index 0000000..7d3454c Binary files /dev/null and b/e2e-tests/options.spec.ts-snapshots/page-json-webkit-docker.png differ diff --git a/e2e-tests/options.spec.ts-snapshots/page-markdown-chromium-docker.png b/e2e-tests/options.spec.ts-snapshots/page-markdown-chromium-docker.png new file mode 100644 index 0000000..8e6c3ff Binary files /dev/null and b/e2e-tests/options.spec.ts-snapshots/page-markdown-chromium-docker.png differ diff --git a/e2e-tests/options.spec.ts-snapshots/page-markdown-firefox-docker.png b/e2e-tests/options.spec.ts-snapshots/page-markdown-firefox-docker.png new file mode 100644 index 0000000..b7d4824 Binary files /dev/null and b/e2e-tests/options.spec.ts-snapshots/page-markdown-firefox-docker.png differ diff --git a/e2e-tests/options.spec.ts-snapshots/page-markdown-webkit-docker.png b/e2e-tests/options.spec.ts-snapshots/page-markdown-webkit-docker.png new file mode 100644 index 0000000..9c5ae38 Binary files /dev/null and b/e2e-tests/options.spec.ts-snapshots/page-markdown-webkit-docker.png differ diff --git a/e2e-tests/tools.spec.ts b/e2e-tests/tools.spec.ts new file mode 100644 index 0000000..23a4019 --- /dev/null +++ b/e2e-tests/tools.spec.ts @@ -0,0 +1,33 @@ +/** + * Tests for the Code Analysis Tools Panel. + */ +import { test, expect } from "@playwright/test"; + +/** + * This test verifies that: + * - Users can switch between different code analysis tools (AST, Scope, Code Path) + * - Each tool displays correctly + * - Tool-specific interactions work as expected (e.g. scope selection) + */ +test("should switch to each tool and show it", async ({ page }) => { + await page.goto("/"); + + await expect( + page.getByRole("region", { name: "Code Analysis Tools Panel" }), + ).toHaveScreenshot("tools-ast.png"); + + await page.getByRole("button", { name: "Scope" }).click(); + await page.getByRole("button", { name: "global" }).click(); + // move mouse away to avoid accordion hover state + await page.mouse.move(0, 0); + + await expect( + page.getByRole("region", { name: "Code Analysis Tools Panel" }), + ).toHaveScreenshot("tools-scope.png"); + + await page.getByRole("button", { name: "Code Path" }).click(); + + await expect( + page.getByRole("region", { name: "Code Analysis Tools Panel" }), + ).toHaveScreenshot("tools-code-path.png"); +}); diff --git a/e2e-tests/tools.spec.ts-snapshots/tools-ast-chromium-docker.png b/e2e-tests/tools.spec.ts-snapshots/tools-ast-chromium-docker.png new file mode 100644 index 0000000..f0a694c Binary files /dev/null and b/e2e-tests/tools.spec.ts-snapshots/tools-ast-chromium-docker.png differ diff --git a/e2e-tests/tools.spec.ts-snapshots/tools-ast-firefox-docker.png b/e2e-tests/tools.spec.ts-snapshots/tools-ast-firefox-docker.png new file mode 100644 index 0000000..ea6a6f2 Binary files /dev/null and b/e2e-tests/tools.spec.ts-snapshots/tools-ast-firefox-docker.png differ diff --git a/e2e-tests/tools.spec.ts-snapshots/tools-ast-webkit-docker.png b/e2e-tests/tools.spec.ts-snapshots/tools-ast-webkit-docker.png new file mode 100644 index 0000000..5c4a214 Binary files /dev/null and b/e2e-tests/tools.spec.ts-snapshots/tools-ast-webkit-docker.png differ diff --git a/e2e-tests/tools.spec.ts-snapshots/tools-code-path-chromium-docker.png b/e2e-tests/tools.spec.ts-snapshots/tools-code-path-chromium-docker.png new file mode 100644 index 0000000..cc51d38 Binary files /dev/null and b/e2e-tests/tools.spec.ts-snapshots/tools-code-path-chromium-docker.png differ diff --git a/e2e-tests/tools.spec.ts-snapshots/tools-code-path-firefox-docker.png b/e2e-tests/tools.spec.ts-snapshots/tools-code-path-firefox-docker.png new file mode 100644 index 0000000..28965bf Binary files /dev/null and b/e2e-tests/tools.spec.ts-snapshots/tools-code-path-firefox-docker.png differ diff --git a/e2e-tests/tools.spec.ts-snapshots/tools-code-path-webkit-docker.png b/e2e-tests/tools.spec.ts-snapshots/tools-code-path-webkit-docker.png new file mode 100644 index 0000000..f1dbb40 Binary files /dev/null and b/e2e-tests/tools.spec.ts-snapshots/tools-code-path-webkit-docker.png differ diff --git a/e2e-tests/tools.spec.ts-snapshots/tools-scope-chromium-docker.png b/e2e-tests/tools.spec.ts-snapshots/tools-scope-chromium-docker.png new file mode 100644 index 0000000..b29ae03 Binary files /dev/null and b/e2e-tests/tools.spec.ts-snapshots/tools-scope-chromium-docker.png differ diff --git a/e2e-tests/tools.spec.ts-snapshots/tools-scope-firefox-docker.png b/e2e-tests/tools.spec.ts-snapshots/tools-scope-firefox-docker.png new file mode 100644 index 0000000..36a9909 Binary files /dev/null and b/e2e-tests/tools.spec.ts-snapshots/tools-scope-firefox-docker.png differ diff --git a/e2e-tests/tools.spec.ts-snapshots/tools-scope-webkit-docker.png b/e2e-tests/tools.spec.ts-snapshots/tools-scope-webkit-docker.png new file mode 100644 index 0000000..e5ed90d Binary files /dev/null and b/e2e-tests/tools.spec.ts-snapshots/tools-scope-webkit-docker.png differ diff --git a/package-lock.json b/package-lock.json index b1e2840..241cf18 100644 --- a/package-lock.json +++ b/package-lock.json @@ -53,6 +53,7 @@ "zustand": "^4.5.4" }, "devDependencies": { + "@playwright/test": "1.51.1", "@types/eslint-scope": "^3.7.7", "@types/espree": "^10.0.0", "@types/esquery": "^1.5.4", @@ -60,6 +61,7 @@ "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", "@vitejs/plugin-react": "^4.4.1", + "cross-env": "^7.0.3", "eslint": "^9.25.1", "got": "^14.4.3", "lint-staged": "^15.2.9", @@ -1628,6 +1630,22 @@ "node": ">=14" } }, + "node_modules/@playwright/test": { + "version": "1.51.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.51.1.tgz", + "integrity": "sha512-nM+kEaTSAoVlXmMPH10017vn3FSiFqr/bh4fKg9vmAdMfd9SDqRZNvPSiAHADc/itWak+qPvMPZQOPwCBW7k7Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.51.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@radix-ui/number": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.0.tgz", @@ -4171,6 +4189,25 @@ "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==" }, + "node_modules/cross-env": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz", + "integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.1" + }, + "bin": { + "cross-env": "src/bin/cross-env.js", + "cross-env-shell": "src/bin/cross-env-shell.js" + }, + "engines": { + "node": ">=10.14", + "npm": ">=6", + "yarn": ">=1" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -8310,6 +8347,53 @@ "node": ">= 6" } }, + "node_modules/playwright": { + "version": "1.51.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.51.1.tgz", + "integrity": "sha512-kkx+MB2KQRkyxjYPc3a0wLZZoDczmppyGJIvQ43l+aZihkaVvmu/21kiyaHeHjiFxjxNNFnUncKmcGIyOojsaw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.51.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.51.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.51.1.tgz", + "integrity": "sha512-/crRMj8+j/Nq5s8QcvegseuyeZPxpQCZb6HNk3Sos3BlZyAknRjoyJPFWkpNn8v0+P3WiwqFF8P+zQo4eqiNuw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "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, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/possible-typed-array-names": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz", diff --git a/package.json b/package.json index e7caab9..3d28720 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,9 @@ "lint:js": "eslint --max-warnings 0 .", "lint:fix:js": "eslint --max-warnings 0 . --fix", "fmt": "prettier --write .", - "fmt:check": "prettier --check ." + "fmt:check": "prettier --check .", + "test:e2e": "docker pull mcr.microsoft.com/playwright:v1.51.1-noble && cross-env PW_TEST_CONNECT_WS_ENDPOINT=ws://127.0.0.1:36719/ playwright test", + "test:e2e:update": "npm run test:e2e -- --update-snapshots" }, "lint-staged": { "**/*.{ts,tsx,jsx,js}": [ @@ -79,6 +81,7 @@ "zustand": "^4.5.4" }, "devDependencies": { + "@playwright/test": "1.51.1", "@types/eslint-scope": "^3.7.7", "@types/espree": "^10.0.0", "@types/esquery": "^1.5.4", @@ -86,6 +89,7 @@ "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", "@vitejs/plugin-react": "^4.4.1", + "cross-env": "^7.0.3", "eslint": "^9.25.1", "got": "^14.4.3", "lint-staged": "^15.2.9", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..65eaa07 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,93 @@ +import os from "node:os"; + +import { devices, PlaywrightTestConfig } from "@playwright/test"; + +const countOfCpus = os.cpus().length; +let workers; +if (countOfCpus !== 0) { + if (countOfCpus <= 4) { + workers = countOfCpus; // utilize all logical processors + } else { + // if the number of CPUs is greater than 4, we set it to 4 to limit RAM usage + workers = 4; + } +} + +const isInCi = process.env.CI === "true"; + +const config: PlaywrightTestConfig = { + testDir: "./e2e-tests", + fullyParallel: true, + // fail a Playwright run in CI if some test.only is in the source code + forbidOnly: isInCi, + retries: isInCi ? 1 : 0, + workers, + reporter: isInCi ? [["html"], ["github"]] : "html", + + use: { + baseURL: `http://localhost:5173`, + + // ensure consistent timezone and locale + timezoneId: "America/Los_Angeles", + locale: "en-US", + + // always capture trace and video (seems to not have significant performance impact) + trace: "on", + video: "on", + }, + + expect: { + toHaveScreenshot: { + // screenshots are often not exactly the same for various reasons - allow 5% of pixel difference + maxDiffPixelRatio: 0.05, + pathTemplate: `{testDir}/{testFilePath}-snapshots/{arg}-{projectName}-docker{ext}`, + }, + }, + + projects: [ + { + name: "chromium", + use: { + ...devices["Desktop Chrome"], + // opt into "New Headless" chromium (https://playwright.dev/docs/browsers#chromium-new-headless-mode, https://developer.chrome.com/docs/chromium/headless) + channel: "chromium", + }, + }, + { + name: "firefox", + use: { ...devices["Desktop Firefox"] }, + }, + { + name: "webkit", + use: { ...devices["Desktop Safari"] }, + }, + ], + + webServer: [ + { + // start the Playwright server in a docker container + command: createDockerRunCommand(36719), + url: `http://127.0.0.1:36719/`, + stdout: "pipe", + stderr: "pipe", + timeout: 30_000, + gracefulShutdown: { + signal: "SIGTERM", + timeout: 10_000, + }, + reuseExistingServer: !isInCi, + }, + { + command: "npm run start", + url: "http://localhost:5173", + reuseExistingServer: !isInCi, + }, + ], +}; + +export default config; + +function createDockerRunCommand(port: number) { + const dockerRunCommand = `docker run --rm --init --workdir /home/pwuser --user pwuser --network host mcr.microsoft.com/playwright:v1.51.1-noble /bin/sh -c "npx -y playwright@1.51.1 run-server --port ${port} --host 0.0.0.0"`; + return dockerRunCommand; +} diff --git a/src/App.tsx b/src/App.tsx index 5776a26..863fb46 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -27,7 +27,12 @@ function App() { direction="horizontal" className="border-t h-full" > - <Panel defaultSize={50} minSize={25}> + <Panel + defaultSize={50} + minSize={25} + role="region" + aria-label="Code Editor Panel" + > <EsquerySelectorInput /> <Editor value={code[language]} @@ -47,7 +52,12 @@ function App() { /> </Panel> <PanelResizeHandle className="w-2 bg-gutter dark:bg-gray-600 bg-gray-200 bg-no-repeat bg-center" /> - <Panel defaultSize={50} minSize={25}> + <Panel + defaultSize={50} + minSize={25} + role="region" + aria-label="Code Analysis Tools Panel" + > <div className="bg-muted overflow-auto h-full relative flex flex-col"> <div className="flex sm:items-center flex-col sm:flex-row justify-between p-4 gap-2 z-10"> <ToolSelector /> diff --git a/src/components/LabeledSelect.tsx b/src/components/LabeledSelect.tsx index 1e08e0b..73f3f73 100644 --- a/src/components/LabeledSelect.tsx +++ b/src/components/LabeledSelect.tsx @@ -42,7 +42,7 @@ const LabeledSelect = (props: PanelProps) => { <div className="space-y-1.5"> <Label htmlFor={id}>{label}</Label> <Select value={value} onValueChange={onValueChange}> - <SelectTrigger className="w-full" disabled={isDisabled}> + <SelectTrigger id={id} className="w-full" disabled={isDisabled}> <SelectValue placeholder={placeholder} /> </SelectTrigger> <SelectContent> diff --git a/src/components/ast/css-ast-tree-item.tsx b/src/components/ast/css-ast-tree-item.tsx index c3ec8d1..bfee72e 100644 --- a/src/components/ast/css-ast-tree-item.tsx +++ b/src/components/ast/css-ast-tree-item.tsx @@ -37,7 +37,7 @@ export const CssAstTreeItem: FC<CssAstTreeItemProperties> = ({ {data.type} </AccordionTrigger> <AccordionContent className="p-4 border-t"> - <div className="space-y-1"> + <ul className="space-y-1"> {Object.entries(data).map(item => ( <TreeEntry key={item[0]} @@ -45,7 +45,7 @@ export const CssAstTreeItem: FC<CssAstTreeItemProperties> = ({ esqueryMatchedNodes={esqueryMatchedNodes} /> ))} - </div> + </ul> </AccordionContent> </AccordionItem> ); diff --git a/src/components/ast/javascript-ast-tree-item.tsx b/src/components/ast/javascript-ast-tree-item.tsx index c26e74c..88914d5 100644 --- a/src/components/ast/javascript-ast-tree-item.tsx +++ b/src/components/ast/javascript-ast-tree-item.tsx @@ -35,7 +35,7 @@ export const JavascriptAstTreeItem: FC<JavascriptAstTreeItemProperties> = ({ {data.type} </AccordionTrigger> <AccordionContent className="p-4 border-t"> - <div className="space-y-1"> + <ul className="space-y-1"> {Object.entries(data).map(item => ( <TreeEntry key={item[0]} @@ -43,7 +43,7 @@ export const JavascriptAstTreeItem: FC<JavascriptAstTreeItemProperties> = ({ esqueryMatchedNodes={esqueryMatchedNodes} /> ))} - </div> + </ul> </AccordionContent> </AccordionItem> ); diff --git a/src/components/ast/json-ast-tree-item.tsx b/src/components/ast/json-ast-tree-item.tsx index 7d8ac0b..2724e4c 100644 --- a/src/components/ast/json-ast-tree-item.tsx +++ b/src/components/ast/json-ast-tree-item.tsx @@ -37,7 +37,7 @@ export const JsonAstTreeItem: FC<JsonAstTreeItemProperties> = ({ {data.type} </AccordionTrigger> <AccordionContent className="p-4 border-t"> - <div className="space-y-1"> + <ul className="space-y-1"> {Object.entries(data).map(item => ( <TreeEntry key={item[0]} @@ -45,7 +45,7 @@ export const JsonAstTreeItem: FC<JsonAstTreeItemProperties> = ({ esqueryMatchedNodes={esqueryMatchedNodes} /> ))} - </div> + </ul> </AccordionContent> </AccordionItem> ); diff --git a/src/components/ast/markdown-ast-tree-item.tsx b/src/components/ast/markdown-ast-tree-item.tsx index 25d091a..b188c6c 100644 --- a/src/components/ast/markdown-ast-tree-item.tsx +++ b/src/components/ast/markdown-ast-tree-item.tsx @@ -37,7 +37,7 @@ export const MarkdownAstTreeItem: FC<MarkdownAstTreeItemProperties> = ({ {data.type} </AccordionTrigger> <AccordionContent className="p-4 border-t"> - <div className="space-y-1"> + <ul className="space-y-1"> {Object.entries(data).map(item => ( <TreeEntry key={item[0]} @@ -45,7 +45,7 @@ export const MarkdownAstTreeItem: FC<MarkdownAstTreeItemProperties> = ({ esqueryMatchedNodes={esqueryMatchedNodes} /> ))} - </div> + </ul> </AccordionContent> </AccordionItem> ); diff --git a/src/components/editor.tsx b/src/components/editor.tsx index 350271c..476761e 100644 --- a/src/components/editor.tsx +++ b/src/components/editor.tsx @@ -165,6 +165,7 @@ export const Editor: FC<EditorProperties> = ({ </div> )} <CodeMirror + aria-label="Code Editor" className="h-full overflow-auto scrollbar-thumb scrollbar-track text-sm" value={value} extensions={editorExtensions} diff --git a/src/components/options.tsx b/src/components/options.tsx index 216e0c6..f51f1a2 100644 --- a/src/components/options.tsx +++ b/src/components/options.tsx @@ -222,7 +222,11 @@ export const Options: FC = () => { return ( <Popover> <PopoverTrigger asChild> - <Button variant="outline" className="flex items-center gap-1.5"> + <Button + aria-label="Language Options" + variant="outline" + className="flex items-center gap-1.5" + > <img src={currentLanguage.icon} alt={currentLanguage.label} diff --git a/src/components/scope/scope-item.tsx b/src/components/scope/scope-item.tsx index 4b477c7..d612f3f 100644 --- a/src/components/scope/scope-item.tsx +++ b/src/components/scope/scope-item.tsx @@ -59,7 +59,7 @@ export const ScopeItem: FC<ScopeItemProperties> = ({ {`${Math.max(index, 0)}. ${key}`} </AccordionTrigger> <AccordionContent className="p-4 border-t"> - <div className="space-y-1"> + <ul className="space-y-1"> {properties.map((item, index) => ( <TreeEntry key={item[0]} @@ -68,7 +68,7 @@ export const ScopeItem: FC<ScopeItemProperties> = ({ esqueryMatchedNodes={esqueryMatchedNodes} /> ))} - </div> + </ul> </AccordionContent> </AccordionItem> ); diff --git a/src/components/tree-entry.tsx b/src/components/tree-entry.tsx index 01991a7..68bcc8d 100644 --- a/src/components/tree-entry.tsx +++ b/src/components/tree-entry.tsx @@ -109,11 +109,11 @@ export const TreeEntry: FC<TreeEntryProperties> = ({ return ( <> - <div className="flex items-center gap-3"> + <li className="flex items-center gap-3"> {isToggleable ? ( <button onClick={toggleOpen} - aria-label="Toggle" + aria-label="Toggle Property" type="button" > <Icon size={16} className="text-muted-foreground" /> @@ -143,7 +143,7 @@ export const TreeEntry: FC<TreeEntryProperties> = ({ {part} </span> ))} - </div> + </li> {open ? ( <SanitizeValue path={path} diff --git a/src/components/ui/popover.tsx b/src/components/ui/popover.tsx index 3efc1d6..f874177 100644 --- a/src/components/ui/popover.tsx +++ b/src/components/ui/popover.tsx @@ -7,7 +7,13 @@ import { cn } from "@/lib/utils"; const Popover = PopoverPrimitive.Root; -const PopoverTrigger = PopoverPrimitive.Trigger; +const PopoverTrigger = React.forwardRef< + React.ElementRef<typeof PopoverPrimitive.Trigger>, + React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Trigger> +>(({ ...props }, ref) => ( + <PopoverPrimitive.Trigger ref={ref} aria-haspopup="dialog" {...props} /> +)); +PopoverTrigger.displayName = PopoverPrimitive.Trigger.displayName; const PopoverContent = React.forwardRef< React.ElementRef<typeof PopoverPrimitive.Content>, @@ -18,6 +24,7 @@ const PopoverContent = React.forwardRef< ref={ref} align={align} sideOffset={sideOffset} + role="dialog" className={cn( "z-50 w-72 rounded-md border border-card bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", className, diff --git a/vite.config.ts b/vite.config.ts index 12d2773..d489a45 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -21,4 +21,8 @@ export default defineConfig({ define: { "process.env.NODE_DEBUG": JSON.stringify(process.env.NODE_DEBUG), }, + server: { + // accept connections from everywhere because Playwright browsers run from within a Docker container which has some random IP + allowedHosts: true, + }, });