├── .eslintignore ├── .styluaignore ├── .gitignore ├── .prettierignore ├── .envrc ├── lua ├── neotest-playwright │ ├── types │ │ └── adapter.lua │ ├── adapter-data.lua │ ├── consumers │ │ ├── init.lua │ │ ├── util.lua │ │ └── attachment.lua │ ├── logging.lua │ ├── helpers.lua │ ├── commands.lua │ ├── results.lua │ ├── adapter-options.lua │ ├── config.lua │ ├── preset.lua │ ├── preset-options.lua │ ├── persist.lua │ ├── build-command.lua │ ├── init.lua │ ├── build-spec.lua │ ├── project.lua │ ├── pickers.lua │ ├── util.lua │ ├── report-io.lua │ ├── finders.lua │ ├── playwright.lua │ ├── position.lua │ ├── select-multiple.lua │ ├── discover.lua │ └── report.lua └── neotest-playwright-assertions │ └── init.lua ├── .prettierrc ├── vitest.config.ts ├── tests ├── init.vim ├── README.md ├── sample │ ├── example.spec.ts │ ├── playwright.config.ts │ └── report.json ├── parse.spec.ts └── init_spec.lua ├── .editorconfig ├── stylua.toml ├── src ├── helpers.ts ├── adapter-data.ts ├── consumers │ ├── init.lua │ ├── util.lua │ └── attachment.lua ├── types │ ├── pickers.d.ts │ ├── util.d.ts │ ├── vim.d.ts │ ├── adapter.ts │ └── neotest.d.ts ├── logging.ts ├── report-io.ts ├── results.ts ├── config.ts ├── adapter-options.ts ├── commands.ts ├── preset-options.ts ├── preset.ts ├── finders.ts ├── playwright.ts ├── persist.ts ├── init.ts ├── build-command.ts ├── select-multiple.ts ├── build-spec.ts ├── project.ts ├── position.ts ├── pickers.lua ├── report.ts ├── discover.ts └── util.lua ├── .neoconf.json ├── scripts ├── test └── fix-require-paths.ts ├── tsconfig.build.json ├── Taskfile.yml ├── flake.nix ├── LICENSE.md ├── tsconfig.json ├── package.json ├── .eslintrc.js ├── flake.lock └── README.md /.eslintignore: -------------------------------------------------------------------------------- 1 | tests/sample/**/* 2 | -------------------------------------------------------------------------------- /.styluaignore: -------------------------------------------------------------------------------- 1 | lua/neotest-playwright 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .task 3 | Session.vim 4 | .direnv 5 | .pre-commit-config.yaml 6 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .task 3 | 4 | lua 5 | License.md 6 | pnpm-lock.yaml 7 | -------------------------------------------------------------------------------- /.envrc: -------------------------------------------------------------------------------- 1 | # shellcheck disable=SC2034,SC2148 2 | if command -v nix-shell &>/dev/null; then 3 | use flake 4 | fi 5 | -------------------------------------------------------------------------------- /lua/neotest-playwright/types/adapter.lua: -------------------------------------------------------------------------------- 1 | --[[ Generated with https://github.com/TypeScriptToLua/TypeScriptToLua ]] 2 | local ____exports = {} 3 | return ____exports 4 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json.schemastore.org/prettierrc", 3 | "useTabs": true, 4 | "singleQuote": true, 5 | "trailingComma": "all", 6 | "printWidth": 80 7 | } 8 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config'; 2 | 3 | export default defineConfig({ 4 | test: { 5 | exclude: ['node_modules', '**/sample/**'], 6 | }, 7 | }); 8 | -------------------------------------------------------------------------------- /tests/init.vim: -------------------------------------------------------------------------------- 1 | set rtp+=. 2 | " set rtp+=../plenary.nvim 3 | " set rtp+=../nvim-treesitter 4 | packadd! plenary.nvim 5 | packadd! nvim-treesitter 6 | packadd! neotest 7 | runtime! plugin/plenary.vim 8 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | [*] 2 | indent_style = tab 3 | indent_size = 2 4 | trim_trailing_whitespace = true 5 | end_of_line = lf 6 | 7 | [*.{yml,yaml}] 8 | indent_style = space 9 | 10 | [*.{md}] 11 | indent_style = space 12 | -------------------------------------------------------------------------------- /stylua.toml: -------------------------------------------------------------------------------- 1 | column_width = 120 2 | line_endings = "Unix" 3 | indent_type = "Tabs" 4 | indent_width = 2 5 | quote_style = "AutoPreferSingle" 6 | call_parentheses = "Always" 7 | 8 | [sort_requires] 9 | enabled = true 10 | -------------------------------------------------------------------------------- /lua/neotest-playwright/adapter-data.lua: -------------------------------------------------------------------------------- 1 | --[[ Generated with https://github.com/TypeScriptToLua/TypeScriptToLua ]] 2 | local ____exports = {} 3 | ____exports.data = {projects = nil, report = nil, specs = nil, rootDir = nil} 4 | return ____exports 5 | -------------------------------------------------------------------------------- /tests/README.md: -------------------------------------------------------------------------------- 1 | ### To test: 2 | 3 | - Test discovery 4 | 5 | - Result parsing 6 | - pass/fail 7 | - does subtelty of skipped vs untested require any special handling? 8 | 9 | - NeotestPlaywrightProject 10 | - NeotestPlaywrightPreset 11 | -------------------------------------------------------------------------------- /src/helpers.ts: -------------------------------------------------------------------------------- 1 | import { logger } from './logging'; 2 | 3 | export const emitError = (msg: string) => { 4 | logger('error', msg); 5 | vim.defer_fn( 6 | () => vim.cmd(`echohl WarningMsg | echo "${msg}" | echohl None`), 7 | 0, 8 | ); 9 | }; 10 | -------------------------------------------------------------------------------- /src/adapter-data.ts: -------------------------------------------------------------------------------- 1 | import type { Adapter } from './types/adapter'; 2 | 3 | // Options is in it's own file to avoid circular dependencies. 4 | export const data: Adapter['data'] = { 5 | projects: null, 6 | report: null, 7 | specs: null, 8 | rootDir: null, 9 | }; 10 | -------------------------------------------------------------------------------- /src/consumers/init.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | 3 | --- @param client neotest.Client 4 | M.consumers = function(client) 5 | return { 6 | attachment = function() 7 | require('neotest-playwright.consumers.attachment').attachment(client) 8 | end, 9 | } 10 | end 11 | 12 | return M 13 | -------------------------------------------------------------------------------- /lua/neotest-playwright/consumers/init.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | 3 | --- @param client neotest.Client 4 | M.consumers = function(client) 5 | return { 6 | attachment = function() 7 | require('neotest-playwright.consumers.attachment').attachment(client) 8 | end, 9 | } 10 | end 11 | 12 | return M 13 | -------------------------------------------------------------------------------- /src/types/pickers.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'neotest-playwright.pickers' { 2 | /** Shows a project picker. */ 3 | const show_picker: ( 4 | opts: Record | null, 5 | np_opts: { 6 | prompt: string; 7 | choices: string[]; 8 | preselected: string[]; 9 | on_select: (this: void, selection: string[]) => void; 10 | }, 11 | ) => void; 12 | } 13 | -------------------------------------------------------------------------------- /src/logging.ts: -------------------------------------------------------------------------------- 1 | import * as neotest_logger from 'neotest.logging'; 2 | 3 | type LogLevel = keyof typeof neotest_logger; 4 | 5 | /** Wrapper around `neotest.logging` that adds a prefix to the log message. */ 6 | export const logger = ( 7 | level: LogLevel, 8 | message: string, 9 | ...args: unknown[] 10 | ) => { 11 | const prefix = '[neotest-playwright]'; 12 | 13 | neotest_logger[level](`${prefix} ${message}`, ...args); 14 | }; 15 | -------------------------------------------------------------------------------- /src/consumers/util.lua: -------------------------------------------------------------------------------- 1 | local async = require('neotest.async') 2 | 3 | local M = {} 4 | 5 | --- @param client neotest.Client 6 | M.get_pos = function(client) 7 | local file = async.fn.expand('%:p') 8 | local row = async.fn.getpos('.')[2] - 1 9 | 10 | local position = client:get_nearest(file, row, {}) 11 | if not position then 12 | print('no position found') 13 | return 14 | end 15 | 16 | return position 17 | end 18 | 19 | return M 20 | -------------------------------------------------------------------------------- /lua/neotest-playwright/logging.lua: -------------------------------------------------------------------------------- 1 | --[[ Generated with https://github.com/TypeScriptToLua/TypeScriptToLua ]] 2 | local ____exports = {} 3 | local neotest_logger = require("neotest.logging") 4 | --- Wrapper around `neotest.logging` that adds a prefix to the log message. 5 | ____exports.logger = function(level, message, ...) 6 | local prefix = "[neotest-playwright]" 7 | neotest_logger[level]((prefix .. " ") .. message, ...) 8 | end 9 | return ____exports 10 | -------------------------------------------------------------------------------- /lua/neotest-playwright/consumers/util.lua: -------------------------------------------------------------------------------- 1 | local async = require('neotest.async') 2 | 3 | local M = {} 4 | 5 | --- @param client neotest.Client 6 | M.get_pos = function(client) 7 | local file = async.fn.expand('%:p') 8 | local row = async.fn.getpos('.')[2] - 1 9 | 10 | local position = client:get_nearest(file, row, {}) 11 | if not position then 12 | print('no position found') 13 | return 14 | end 15 | 16 | return position 17 | end 18 | 19 | return M 20 | -------------------------------------------------------------------------------- /.neoconf.json: -------------------------------------------------------------------------------- 1 | { 2 | "neodev": { 3 | "library": { 4 | "enabled": true, 5 | "types": true, 6 | "plugins": [ 7 | "neoconf.nvim", 8 | "nvim-lspconfig", 9 | "neotest", 10 | "nvim-dap-ui", 11 | "nvim-dap", 12 | "nvim-dap-virtual-text" 13 | ] 14 | } 15 | }, 16 | "neoconf": { 17 | "plugins": { 18 | "sumneko_lua": { 19 | "enabled": true 20 | } 21 | } 22 | }, 23 | "lspconfig": { 24 | "sumneko_lua": {} 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /lua/neotest-playwright/helpers.lua: -------------------------------------------------------------------------------- 1 | --[[ Generated with https://github.com/TypeScriptToLua/TypeScriptToLua ]] 2 | local ____exports = {} 3 | local ____logging = require('neotest-playwright.logging') 4 | local logger = ____logging.logger 5 | ____exports.emitError = function(msg) 6 | logger("error", msg) 7 | vim.defer_fn( 8 | function() return vim.cmd(("echohl WarningMsg | echo \"" .. msg) .. "\" | echohl None") end, 9 | 0 10 | ) 11 | end 12 | return ____exports 13 | -------------------------------------------------------------------------------- /tests/sample/example.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from '@playwright/test'; 2 | 3 | test.describe('math', () => { 4 | test('addition', async ({ page }) => { 5 | expect(1 + 1).toBe(2); 6 | }); 7 | 8 | test('not substraction', async ({ page }) => { 9 | expect(1 - 1).toBe(11); 10 | }); 11 | }); 12 | 13 | test('common sense', async ({ page }) => { 14 | expect(true).toBe(true); 15 | }); 16 | 17 | test('not so common sense', async ({ page }) => { 18 | expect(true).toBe(false); 19 | }); 20 | -------------------------------------------------------------------------------- /src/report-io.ts: -------------------------------------------------------------------------------- 1 | import type * as P from '@playwright/test/reporter'; 2 | import * as lib from 'neotest.lib'; 3 | 4 | export const readReport = (file: string) => { 5 | const [success, data] = pcall(lib.files.read, file); 6 | 7 | if (!success) { 8 | throw new Error(`Failed to read test output file: ${file}`); 9 | } 10 | 11 | const [ok, parsed] = pcall(vim.json.decode, data, { 12 | luanil: { object: true }, 13 | }); 14 | 15 | if (!ok) { 16 | throw new Error(`Failed to parse test output json: ${file}`); 17 | } 18 | 19 | return parsed as P.JSONReport; 20 | }; 21 | -------------------------------------------------------------------------------- /src/types/util.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'neotest-playwright.util' { 2 | /** Find the closest ancestor containing a file or directory with the given name. 3 | * @param startpath The path to start searching from. 4 | * @param name The name of the file or directory to search for. 5 | * @param is_dir Whether to search for a directory or a file. 6 | * @returns The path to the file or directory, or null if not found. 7 | */ 8 | const find_ancestor: ( 9 | startpath: string, 10 | name: string, 11 | is_dir: boolean, 12 | ) => string | null; 13 | 14 | const cleanAnsi: (str: string) => string; 15 | } 16 | -------------------------------------------------------------------------------- /lua/neotest-playwright-assertions/init.lua: -------------------------------------------------------------------------------- 1 | local s = require('say') 2 | 3 | function Contains(state, arguments) 4 | if not type(arguments[1]) == 'table' or #arguments ~= 2 then 5 | return false 6 | end 7 | 8 | for _, val in ipairs(arguments[1]) do 9 | if val == arguments[2] then 10 | return true 11 | end 12 | end 13 | 14 | return false 15 | end 16 | 17 | s:set('assertion.Contains.positive', 'Expected %s \nto contain: %s') 18 | s:set('assertion.Contains.negative', 'Expected %s \nto not contain: %s') 19 | assert:register('assertion', 'Contains', Contains, 'assertion.Contains.positive', 'assertion.Contains.negative') 20 | -------------------------------------------------------------------------------- /src/results.ts: -------------------------------------------------------------------------------- 1 | import { parseOutput } from 'neotest-playwright/report'; 2 | import { logger } from './logging'; 3 | import { readReport } from './report-io'; 4 | import type { Adapter } from './types/adapter'; 5 | 6 | export const results: Adapter['results'] = (spec, result, _tree) => { 7 | if (result.code === 129) { 8 | logger( 9 | 'debug', 10 | 'Code 129: User stopped the test run. Aborting result parse', 11 | ); 12 | return {}; 13 | } 14 | 15 | const resultsPath = spec.context.results_path; 16 | 17 | const decoded = readReport(resultsPath); 18 | 19 | const results = parseOutput(decoded); 20 | 21 | return results; 22 | }; 23 | -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | import { buildSpec } from 'neotest-playwright/build-spec'; 2 | import { 3 | discoverPositions, 4 | filterDir, 5 | isTestFile, 6 | root, 7 | } from 'neotest-playwright/discover'; 8 | import { results } from 'neotest-playwright/results'; 9 | import { data } from './adapter-data'; 10 | import { options } from './adapter-options'; 11 | import type { Adapter } from './types/adapter'; 12 | 13 | export const config: Readonly = { 14 | name: 'neotest-playwright', 15 | is_test_file: isTestFile, 16 | root: root, 17 | filter_dir: filterDir, 18 | discover_positions: discoverPositions, 19 | build_spec: buildSpec, 20 | results: results, 21 | options: options, 22 | data: data, 23 | }; 24 | -------------------------------------------------------------------------------- /scripts/test: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | tempfile=".test_output.tmp" 3 | 4 | if [[ -n $1 ]]; then 5 | nvim --headless --noplugin -u tests/init.vim -c 'packadd plenary.nvim' -c "PlenaryBustedFile $1" | tee "${tempfile}" 6 | else 7 | nvim --headless --noplugin -u tests/init.vim -c 'packadd plenary.nvim' -c "PlenaryBustedDirectory tests/playwright {minimal_init = 'tests/init.vim'}" | tee "${tempfile}" 8 | fi 9 | 10 | # Plenary doesn't emit exit code 1 when tests have errors during setup 11 | errors=$(sed 's/\x1b\[[0-9;]*m//g' "${tempfile}" | awk '/(Errors|Failed) :/ {print $3}' | grep -v '0') 12 | 13 | rm "${tempfile}" 14 | 15 | if [[ -n $errors ]]; then 16 | echo "Tests failed" 17 | exit 1 18 | fi 19 | 20 | exit 0 21 | -------------------------------------------------------------------------------- /src/adapter-options.ts: -------------------------------------------------------------------------------- 1 | import { getPlaywrightBinary, getPlaywrightConfig, get_cwd } from './finders'; 2 | import type { Adapter } from './types/adapter'; 3 | 4 | // Options is in it's own file to avoid circular dependencies. 5 | export const options: Adapter['options'] = { 6 | projects: [], 7 | preset: 'none', 8 | persist_project_selection: false, 9 | get_playwright_binary: getPlaywrightBinary, 10 | get_playwright_config: getPlaywrightConfig, 11 | get_cwd: get_cwd, 12 | env: {}, 13 | extra_args: [], 14 | tempDataFile: vim.fn.stdpath('data') + '/neotest-playwright-test-list.json', 15 | enable_dynamic_test_discovery: false, 16 | experimental: { 17 | telescope: { 18 | enabled: false, 19 | opts: {}, 20 | }, 21 | }, 22 | }; 23 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://raw.githubusercontent.com/TypeScriptToLua/TypeScriptToLua/master/tsconfig-schema.json", 3 | "extends": "./tsconfig.json", 4 | "compilerOptions": { 5 | "outDir": "lua/neotest-playwright" 6 | }, 7 | "include": ["src"], 8 | "exclude": ["tests"], 9 | "tstl": { 10 | // Docs: https://typescripttolua.github.io/docs/configuration#custom-options 11 | "luaTarget": "JIT", 12 | "tstlVerbose": false, 13 | "luaLibImport": "inline", 14 | "noImplicitSelf": true, 15 | "noResolvePaths": [ 16 | "neotest.async", 17 | "neotest.lib", 18 | "neotest.logging", 19 | "neotest-playwright.pickers", // copied manually 20 | "neotest-playwright.util" // copied manually 21 | ] 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Taskfile.yml: -------------------------------------------------------------------------------- 1 | # yaml-language-server: $schema=https://json.schemastore.org/taskfile.json 2 | --- 3 | version: '3' 4 | 5 | tasks: 6 | build: 7 | dir: '{{.USER_WORKING_DIR}}' 8 | sources: 9 | - src/**/* 10 | - scripts/**/* 11 | - tsconfig* 12 | cmds: 13 | - rm -rf lua/neotest-playwright 14 | - tstl -p tsconfig.build.json {{.CLI_ARGS}} 15 | - cp src/util.lua lua/neotest-playwright/util.lua 16 | - cp src/pickers.lua lua/neotest-playwright/pickers.lua 17 | - cp -r src/consumers lua/neotest-playwright/consumers 18 | - tsx ./scripts/fix-require-paths.ts 19 | 20 | format: 21 | dir: '{{.USER_WORKING_DIR}}' 22 | cmds: 23 | - prettier --{{.ACTION | default "check"}} "**/*.{js,cjs,mjs,ts,cts,mts,md,yaml,yml,json,jsonc}" 24 | -------------------------------------------------------------------------------- /src/commands.ts: -------------------------------------------------------------------------------- 1 | import * as async from 'neotest.async'; 2 | import * as lib from 'neotest.lib'; 3 | import { refresh_data } from './discover'; 4 | 5 | const refresh_command = () => { 6 | if (lib.subprocess.enabled()) { 7 | // This is async and will wait for the function to return 8 | lib.subprocess.call("require('neotest-playwright.discover').refresh_data"); 9 | } else { 10 | refresh_data(); 11 | } 12 | }; 13 | 14 | export const create_refresh_command = () => { 15 | vim.api.nvim_create_user_command( 16 | 'NeotestPlaywrightRefresh', 17 | // @ts-expect-error until type is updated 18 | () => { 19 | // Wrap with async.run to avoid error: https://github.com/nvim-neotest/neotest/issues/167 20 | async.run(refresh_command); 21 | }, 22 | { 23 | nargs: 0, 24 | }, 25 | ); 26 | }; 27 | -------------------------------------------------------------------------------- /lua/neotest-playwright/commands.lua: -------------------------------------------------------------------------------- 1 | --[[ Generated with https://github.com/TypeScriptToLua/TypeScriptToLua ]] 2 | local ____exports = {} 3 | local async = require("neotest.async") 4 | local lib = require("neotest.lib") 5 | local ____discover = require('neotest-playwright.discover') 6 | local refresh_data = ____discover.refresh_data 7 | local function refresh_command() 8 | if lib.subprocess.enabled() then 9 | lib.subprocess.call("require('neotest-playwright.discover').refresh_data") 10 | else 11 | refresh_data() 12 | end 13 | end 14 | ____exports.create_refresh_command = function() 15 | vim.api.nvim_create_user_command( 16 | "NeotestPlaywrightRefresh", 17 | function() 18 | async.run(refresh_command) 19 | end, 20 | {nargs = 0} 21 | ) 22 | end 23 | return ____exports 24 | -------------------------------------------------------------------------------- /lua/neotest-playwright/results.lua: -------------------------------------------------------------------------------- 1 | --[[ Generated with https://github.com/TypeScriptToLua/TypeScriptToLua ]] 2 | local ____exports = {} 3 | local ____report = require('neotest-playwright.report') 4 | local parseOutput = ____report.parseOutput 5 | local ____logging = require('neotest-playwright.logging') 6 | local logger = ____logging.logger 7 | local ____report_2Dio = require('neotest-playwright.report-io') 8 | local readReport = ____report_2Dio.readReport 9 | ____exports.results = function(spec, result, _tree) 10 | if result.code == 129 then 11 | logger("debug", "Code 129: User stopped the test run. Aborting result parse") 12 | return {} 13 | end 14 | local resultsPath = spec.context.results_path 15 | local decoded = readReport(resultsPath) 16 | local results = parseOutput(decoded) 17 | return results 18 | end 19 | return ____exports 20 | -------------------------------------------------------------------------------- /lua/neotest-playwright/adapter-options.lua: -------------------------------------------------------------------------------- 1 | --[[ Generated with https://github.com/TypeScriptToLua/TypeScriptToLua ]] 2 | local ____exports = {} 3 | local ____finders = require('neotest-playwright.finders') 4 | local getPlaywrightBinary = ____finders.getPlaywrightBinary 5 | local getPlaywrightConfig = ____finders.getPlaywrightConfig 6 | local get_cwd = ____finders.get_cwd 7 | ____exports.options = { 8 | projects = {}, 9 | preset = "none", 10 | persist_project_selection = false, 11 | get_playwright_binary = getPlaywrightBinary, 12 | get_playwright_config = getPlaywrightConfig, 13 | get_cwd = get_cwd, 14 | env = {}, 15 | extra_args = {}, 16 | tempDataFile = vim.fn.stdpath("data") .. "/neotest-playwright-test-list.json", 17 | enable_dynamic_test_discovery = false, 18 | experimental = {telescope = {enabled = false, opts = {}}} 19 | } 20 | return ____exports 21 | -------------------------------------------------------------------------------- /src/preset-options.ts: -------------------------------------------------------------------------------- 1 | import type { CommandOptionsPreset } from './build-command'; 2 | 3 | export const PRESET = { 4 | HEADED: 'headed', 5 | DEBUG: 'debug', 6 | NONE: 'none', 7 | } as const; 8 | 9 | export type Preset = (typeof PRESET)[keyof typeof PRESET]; 10 | 11 | export const isPreset = (x: unknown): x is Preset => { 12 | return Object.values(PRESET).includes(x as Preset); 13 | }; 14 | 15 | const COMMAND_HEADED = { 16 | headed: true, 17 | retries: 0, 18 | abortOnFailure: true, 19 | workers: 1, 20 | timeout: 0, 21 | } satisfies CommandOptionsPreset; 22 | 23 | const COMMAND_DEBUG = { 24 | debug: true, 25 | } satisfies CommandOptionsPreset; 26 | 27 | /** No preset, use default options. */ 28 | export const COMMAND_NONE = {} satisfies CommandOptionsPreset; 29 | 30 | export const COMMAND_PRESETS = { 31 | headed: COMMAND_HEADED, 32 | debug: COMMAND_DEBUG, 33 | none: COMMAND_NONE, 34 | } satisfies Record; 35 | -------------------------------------------------------------------------------- /tests/sample/playwright.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig, devices } from '@playwright/test'; 2 | 3 | export default defineConfig({ 4 | testDir: './tests', 5 | reporter: 'html', 6 | projects: [ 7 | { 8 | name: 'chromium', 9 | use: { ...devices['Desktop Chrome'] }, 10 | }, 11 | 12 | { 13 | name: 'firefox', 14 | use: { ...devices['Desktop Firefox'] }, 15 | }, 16 | 17 | { 18 | name: 'webkit', 19 | use: { ...devices['Desktop Safari'] }, 20 | }, 21 | 22 | /* Test against mobile viewports. */ 23 | // { 24 | // name: 'Mobile Chrome', 25 | // use: { ...devices['Pixel 5'] }, 26 | // }, 27 | // { 28 | // name: 'Mobile Safari', 29 | // use: { ...devices['iPhone 12'] }, 30 | // }, 31 | 32 | /* Test against branded browsers. */ 33 | // { 34 | // name: 'Microsoft Edge', 35 | // use: { channel: 'msedge' }, 36 | // }, 37 | // { 38 | // name: 'Google Chrome', 39 | // use: { channel: 'chrome' }, 40 | // }, 41 | ], 42 | }); 43 | -------------------------------------------------------------------------------- /src/preset.ts: -------------------------------------------------------------------------------- 1 | import { options } from './adapter-options'; 2 | import { logger } from './logging'; 3 | import type { Preset } from './preset-options'; 4 | import { isPreset } from './preset-options'; 5 | 6 | export const set_preset = (preset: Preset) => { 7 | options.preset = preset; 8 | }; 9 | 10 | export const select_preset = (on_submit: (selection: Preset) => void) => { 11 | const choices = ['headed', 'debug', 'none'] satisfies Preset[]; 12 | 13 | const prompt = 'Select preset for neotest-playwright:'; 14 | 15 | let choice: unknown; 16 | 17 | vim.ui.select(choices, { prompt }, (c) => { 18 | choice = c; 19 | 20 | logger('debug', 'preset', choice); 21 | 22 | if (isPreset(choice)) { 23 | on_submit(choice); 24 | } 25 | }); 26 | }; 27 | 28 | export const create_preset_command = () => { 29 | vim.api.nvim_create_user_command( 30 | 'NeotestPlaywrightPreset', 31 | // @ts-expect-error until type is updated 32 | () => select_preset((choice) => set_preset(choice)), 33 | { 34 | nargs: 0, 35 | }, 36 | ); 37 | }; 38 | -------------------------------------------------------------------------------- /src/types/vim.d.ts: -------------------------------------------------------------------------------- 1 | // merged with the global IVim interface provided by "@gkzhb/lua-types-nvim" 2 | interface IVim { 3 | json: { 4 | decode: ( 5 | this: void, 6 | value: string, 7 | options?: { luanil: { object: true } }, 8 | ) => unknown; 9 | }; 10 | 11 | loop: { 12 | fs_scandir: (this: void, path: string) => unknown[]; 13 | dirname: (this: void, path: string) => string; 14 | cwd: (this: void) => string; 15 | }; 16 | 17 | ui: { 18 | select: ( 19 | this: void, 20 | items: string[], 21 | opts: { 22 | prompt: string; 23 | format_item?: (this: void, item: string) => string; 24 | }, 25 | on_choice: (choice: string) => void, 26 | ) => void; 27 | }; 28 | 29 | cmd: (this: void, cmd: string) => void; 30 | 31 | // TODO: override api.nvim_create_user_command only, without affecting other api.* functions 32 | // api: { 33 | // nvim_create_user_command: ( 34 | // this: void, 35 | // name: string, 36 | // fn: () => void, 37 | // opts?: { nargs: 0 }, 38 | // ) => void; 39 | // }; 40 | } 41 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | inputs = { 3 | nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; 4 | flake-utils.url = "github:numtide/flake-utils"; 5 | pre-commit-hooks.url = "github:cachix/pre-commit-hooks.nix"; 6 | }; 7 | 8 | outputs = { self, nixpkgs, flake-utils, ... } @ inputs: 9 | flake-utils.lib.eachDefaultSystem (system: 10 | let 11 | pkgs = nixpkgs.legacyPackages.${system}; 12 | in 13 | { 14 | devShells = { 15 | default = pkgs.mkShell { 16 | inherit (self.checks.${system}.pre-commit-check) shellHook; 17 | buildInputs = self.checks.${system}.pre-commit-check.enabledPackages; 18 | # packages = with pkgs; [ ]; 19 | }; 20 | }; 21 | checks = { 22 | pre-commit-check = inputs.pre-commit-hooks.lib.${system}.run { 23 | src = ./.; 24 | hooks = { 25 | nixpkgs-fmt.enable = true; 26 | stylua.enable = true; 27 | }; 28 | }; 29 | }; 30 | } 31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 thenbe 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /lua/neotest-playwright/config.lua: -------------------------------------------------------------------------------- 1 | --[[ Generated with https://github.com/TypeScriptToLua/TypeScriptToLua ]] 2 | local ____exports = {} 3 | local ____build_2Dspec = require('neotest-playwright.build-spec') 4 | local buildSpec = ____build_2Dspec.buildSpec 5 | local ____discover = require('neotest-playwright.discover') 6 | local discoverPositions = ____discover.discoverPositions 7 | local filterDir = ____discover.filterDir 8 | local isTestFile = ____discover.isTestFile 9 | local root = ____discover.root 10 | local ____results = require('neotest-playwright.results') 11 | local results = ____results.results 12 | local ____adapter_2Ddata = require('neotest-playwright.adapter-data') 13 | local data = ____adapter_2Ddata.data 14 | local ____adapter_2Doptions = require('neotest-playwright.adapter-options') 15 | local options = ____adapter_2Doptions.options 16 | ____exports.config = { 17 | name = "neotest-playwright", 18 | is_test_file = isTestFile, 19 | root = root, 20 | filter_dir = filterDir, 21 | discover_positions = discoverPositions, 22 | build_spec = buildSpec, 23 | results = results, 24 | options = options, 25 | data = data 26 | } 27 | return ____exports 28 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "compilerOptions": { 4 | "target": "esnext", 5 | "lib": ["esnext"], 6 | "moduleResolution": "node", 7 | "types": ["node", "lua-types/jit", "@gkzhb/lua-types-nvim"], 8 | "resolveJsonModule": true, 9 | "noImplicitThis": true, 10 | "baseUrl": "src", 11 | "paths": { 12 | "neotest-playwright/*": ["*"] 13 | }, 14 | 15 | // From @tsconfig/node18-strictest-esm/tsconfig.json 16 | "strict": true, 17 | "esModuleInterop": true, 18 | "skipLibCheck": true, 19 | "forceConsistentCasingInFileNames": true, 20 | "allowUnusedLabels": false, 21 | "allowUnreachableCode": false, 22 | "exactOptionalPropertyTypes": true, 23 | "noFallthroughCasesInSwitch": true, 24 | "noImplicitOverride": true, 25 | "noImplicitReturns": true, 26 | "noPropertyAccessFromIndexSignature": true, 27 | "noUncheckedIndexedAccess": true, 28 | "noUnusedLocals": true, 29 | "noUnusedParameters": true, 30 | // "importsNotUsedAsValues": "error", 31 | "checkJs": true 32 | }, 33 | "exclude": ["tests/sample/**"], 34 | "include": [ 35 | // 36 | "./**/*.ts", 37 | "./**/*.js", 38 | "./.*.js" 39 | ] 40 | } 41 | -------------------------------------------------------------------------------- /lua/neotest-playwright/preset.lua: -------------------------------------------------------------------------------- 1 | --[[ Generated with https://github.com/TypeScriptToLua/TypeScriptToLua ]] 2 | local ____exports = {} 3 | local ____adapter_2Doptions = require('neotest-playwright.adapter-options') 4 | local options = ____adapter_2Doptions.options 5 | local ____logging = require('neotest-playwright.logging') 6 | local logger = ____logging.logger 7 | local ____preset_2Doptions = require('neotest-playwright.preset-options') 8 | local isPreset = ____preset_2Doptions.isPreset 9 | ____exports.set_preset = function(preset) 10 | options.preset = preset 11 | end 12 | ____exports.select_preset = function(on_submit) 13 | local choices = {"headed", "debug", "none"} 14 | local prompt = "Select preset for neotest-playwright:" 15 | local choice 16 | vim.ui.select( 17 | choices, 18 | {prompt = prompt}, 19 | function(c) 20 | choice = c 21 | logger("debug", "preset", choice) 22 | if isPreset(choice) then 23 | on_submit(choice) 24 | end 25 | end 26 | ) 27 | end 28 | ____exports.create_preset_command = function() 29 | vim.api.nvim_create_user_command( 30 | "NeotestPlaywrightPreset", 31 | function() return ____exports.select_preset(function(choice) return ____exports.set_preset(choice) end) end, 32 | {nargs = 0} 33 | ) 34 | end 35 | return ____exports 36 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "neotest-playwright", 3 | "version": "0.0.1", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "build": "task build --force", 8 | "dev": "task build --watch --interval=500ms -- --allowUnusedLabels --noUnusedLocals false --noUnusedParameters false --exactOptionalPropertyTypes false --allowUnreachableCode true --noUncheckedIndexedAccess false --strictNullChecks true --suppressExcessPropertyErrors false", 9 | "test": "vitest", 10 | "check": "tsc --noEmit", 11 | "format": "task format", 12 | "format:fix": "ACTION=write task format", 13 | "lint": "eslint --max-warnings 0 --report-unused-disable-directives \"**/*.{js,cjs,mjs,ts,cts,mts}\"" 14 | }, 15 | "keywords": [], 16 | "author": "", 17 | "license": "ISC", 18 | "devDependencies": { 19 | "@gkzhb/lua-types-nvim": "0.0.7", 20 | "@go-task/cli": "3.24.0", 21 | "@playwright/test": "1.44.1", 22 | "@tsconfig/node18-strictest-esm": "1.0.1", 23 | "@types/eslint": "8.37.0", 24 | "@types/node": "18.15.11", 25 | "@typescript-eslint/eslint-plugin": "5.59.0", 26 | "@typescript-eslint/parser": "5.59.0", 27 | "@vitest/ui": "0.30.1", 28 | "eslint": "8.38.0", 29 | "eslint-plugin-eslint-comments": "3.2.0", 30 | "lua-types": "2.13.1", 31 | "magic-string": "0.30.0", 32 | "prettier": "2.8.7", 33 | "tiny-glob": "0.2.9", 34 | "tstl": "2.5.13", 35 | "tsx": "3.12.6", 36 | "typescript": "5.0.4", 37 | "typescript-to-lua": "1.14.0", 38 | "vitest": "0.30.1" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /tests/parse.spec.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unsafe-assignment */ 2 | import type * as P from '@playwright/test/reporter'; 3 | import type * as neotest from 'neotest'; 4 | import { expect, test } from 'vitest'; 5 | import * as report from '../src/report'; 6 | import sample from './sample/report.json'; 7 | 8 | test('parse report', () => { 9 | const results = report.parseOutput(sample as unknown as P.JSONReport); 10 | 11 | const expected = { 12 | '/home/user/project/tests/example.spec.ts::addition': { 13 | status: 'passed', 14 | short: 'addition: passed', 15 | errors: [], 16 | }, 17 | '/home/user/project/tests/example.spec.ts::not substraction': { 18 | status: 'failed', 19 | short: 'not substraction: failed', 20 | errors: expect.arrayContaining([ 21 | expect.objectContaining({ 22 | message: expect.stringMatching('Error: '), 23 | line: 9, 24 | }), 25 | ]), 26 | }, 27 | '/home/user/project/tests/example.spec.ts::common sense': { 28 | status: 'passed', 29 | short: 'common sense: passed', 30 | errors: [], 31 | }, 32 | '/home/user/project/tests/example.spec.ts::not so common sense': { 33 | status: 'failed', 34 | short: 'not so common sense: failed', 35 | errors: expect.arrayContaining([ 36 | expect.objectContaining({ 37 | message: expect.stringMatching('Error: '), 38 | line: 18, 39 | }), 40 | ]), 41 | }, 42 | } satisfies neotest.Results; 43 | 44 | expect(results).toStrictEqual(expected); 45 | }); 46 | -------------------------------------------------------------------------------- /lua/neotest-playwright/preset-options.lua: -------------------------------------------------------------------------------- 1 | --[[ Generated with https://github.com/TypeScriptToLua/TypeScriptToLua ]] 2 | -- Lua Library inline imports 3 | local function __TS__ObjectValues(obj) 4 | local result = {} 5 | local len = 0 6 | for key in pairs(obj) do 7 | len = len + 1 8 | result[len] = obj[key] 9 | end 10 | return result 11 | end 12 | 13 | local function __TS__ArrayIncludes(self, searchElement, fromIndex) 14 | if fromIndex == nil then 15 | fromIndex = 0 16 | end 17 | local len = #self 18 | local k = fromIndex 19 | if fromIndex < 0 then 20 | k = len + fromIndex 21 | end 22 | if k < 0 then 23 | k = 0 24 | end 25 | for i = k + 1, len do 26 | if self[i] == searchElement then 27 | return true 28 | end 29 | end 30 | return false 31 | end 32 | -- End of Lua Library inline imports 33 | local ____exports = {} 34 | ____exports.PRESET = {HEADED = "headed", DEBUG = "debug", NONE = "none"} 35 | ____exports.isPreset = function(x) 36 | return __TS__ArrayIncludes( 37 | __TS__ObjectValues(____exports.PRESET), 38 | x 39 | ) 40 | end 41 | local COMMAND_HEADED = { 42 | headed = true, 43 | retries = 0, 44 | abortOnFailure = true, 45 | workers = 1, 46 | timeout = 0 47 | } 48 | local COMMAND_DEBUG = {debug = true} 49 | --- No preset, use default options. 50 | ____exports.COMMAND_NONE = {} 51 | ____exports.COMMAND_PRESETS = {headed = COMMAND_HEADED, debug = COMMAND_DEBUG, none = ____exports.COMMAND_NONE} 52 | return ____exports 53 | -------------------------------------------------------------------------------- /src/finders.ts: -------------------------------------------------------------------------------- 1 | import * as lib from 'neotest.lib'; 2 | import { logger } from './logging'; 3 | import type { AdapterOptions } from './types/adapter'; 4 | 5 | export const getPlaywrightBinary: AdapterOptions['get_playwright_binary'] = 6 | () => { 7 | const dir = get_cwd(); 8 | 9 | const node_modules = `${dir}/node_modules`; 10 | 11 | const bin = `${node_modules}/.bin/playwright`; 12 | 13 | if (lib.files.exists(bin)) { 14 | return bin; 15 | } else { 16 | logger('error', 'playwright binary does not exist at ', bin); 17 | throw new Error( 18 | 'Unable to locate playwright binary. Expected to find it at: ' + bin, 19 | ); 20 | } 21 | }; 22 | 23 | export const getPlaywrightConfig: AdapterOptions['get_playwright_config'] = 24 | () => { 25 | const dir = get_cwd(); 26 | 27 | const configs = [ 28 | `${dir}/playwright.config.ts`, 29 | `${dir}/playwright.config.js`, 30 | ]; 31 | 32 | for (const config of configs) { 33 | if (lib.files.exists(config)) { 34 | return config; 35 | } 36 | } 37 | 38 | logger('error', 'Unable to locate playwright config file.'); 39 | throw new Error( 40 | 'Unable to locate playwright config file. Expected to find it at: ' + 41 | configs.join(', '), 42 | ); 43 | }; 44 | 45 | export const get_cwd: NonNullable = () => { 46 | // current buffer's path (for non-file buffers, return buffer name. E.g. "Neotest Summary") 47 | // const dir = vim.api.nvim_eval('expand("%:p:h")') as unknown as string; 48 | 49 | const dir = vim.loop.cwd() as unknown as string; 50 | 51 | return dir; 52 | }; 53 | -------------------------------------------------------------------------------- /src/playwright.ts: -------------------------------------------------------------------------------- 1 | import type * as P from '@playwright/test/reporter'; 2 | import { options } from './adapter-options'; 3 | import { buildCommand } from './build-command'; 4 | import { emitError } from './helpers'; 5 | import { logger } from './logging'; 6 | 7 | export const get_config = () => { 8 | const cmd = buildCommand( 9 | { 10 | bin: options.get_playwright_binary(), 11 | config: options.get_playwright_config(), 12 | reporters: ['json'], 13 | }, 14 | ['--list'], 15 | ); 16 | 17 | // apply any custom environment variables when resolving the config 18 | cmd.unshift( 19 | Object.entries(options.env) 20 | .map(([key, value]) => `${key}=${value}`) 21 | .join(' '), 22 | ); 23 | 24 | const output = run(cmd.join(' ')); 25 | 26 | if (!output) { 27 | throw new Error('Failed to get Playwright config'); 28 | } 29 | 30 | return output; 31 | }; 32 | 33 | /** Returns the playwright config */ 34 | const run = (cmd: string) => { 35 | const [handle, errmsg] = io.popen(cmd); 36 | 37 | if (typeof errmsg === 'string') { 38 | logger('error', errmsg); 39 | } 40 | 41 | if (!handle) { 42 | emitError(`Failed to execute command: ${cmd}`); 43 | return; 44 | } 45 | 46 | const output = handle.read('*a'); 47 | handle.close(); 48 | 49 | if (typeof output !== 'string') { 50 | emitError(`Failed to read output from command: ${cmd}`); 51 | return; 52 | } 53 | 54 | if (output === '') { 55 | emitError(`No output from command: ${cmd}`); 56 | return; 57 | } 58 | 59 | const decoded = vim.fn.json_decode(output) as P.JSONReport; 60 | 61 | return decoded; 62 | }; 63 | -------------------------------------------------------------------------------- /lua/neotest-playwright/persist.lua: -------------------------------------------------------------------------------- 1 | --[[ Generated with https://github.com/TypeScriptToLua/TypeScriptToLua ]] 2 | local ____exports = {} 3 | local lib = require("neotest.lib") 4 | local ____logging = require('neotest-playwright.logging') 5 | local logger = ____logging.logger 6 | local current = vim.fn.getcwd() 7 | local dataPath = vim.fn.stdpath("data") 8 | local dataFile = dataPath .. "/neotest-playwright.json" 9 | ____exports.loadCache = function() 10 | logger("debug", "Loading cache", dataFile) 11 | if not lib.files.exists(dataFile) then 12 | return nil 13 | end 14 | local existing = vim.fn.readfile(dataFile) 15 | if #existing == 0 then 16 | return nil 17 | end 18 | local cache = vim.fn.json_decode(existing[1]) 19 | return cache 20 | end 21 | --- Persists the selected projects to disk. Project selection is scoped 22 | -- to project directory. 23 | ____exports.saveCache = function(cache) 24 | logger("debug", "Saving cache", dataFile) 25 | vim.fn.writefile( 26 | {vim.fn.json_encode(cache)}, 27 | dataFile 28 | ) 29 | end 30 | ____exports.loadProjectCache = function() 31 | local cache = ____exports.loadCache() 32 | if cache == nil then 33 | return nil 34 | end 35 | local projectCache = cache[current] or nil 36 | logger("debug", "Loading Project Cache", projectCache) 37 | return projectCache 38 | end 39 | ____exports.saveProjectCache = function(latest) 40 | logger("debug", "Saving Project Cache", latest) 41 | local cache = ____exports.loadCache() or ({}) 42 | cache[current] = latest 43 | ____exports.saveCache(cache) 44 | end 45 | return ____exports 46 | -------------------------------------------------------------------------------- /src/persist.ts: -------------------------------------------------------------------------------- 1 | import * as lib from 'neotest.lib'; 2 | import { logger } from './logging'; 3 | import type { Adapter } from './types/adapter'; 4 | 5 | type ProjectCache = Pick; 6 | 7 | type Cache = Record; 8 | 9 | const current = vim.fn.getcwd(); 10 | 11 | const dataPath = vim.fn.stdpath('data'); 12 | const dataFile = `${dataPath}/neotest-playwright.json`; 13 | 14 | export const loadCache = (): Cache | null => { 15 | logger('debug', 'Loading cache', dataFile); 16 | 17 | if (!lib.files.exists(dataFile)) { 18 | return null; 19 | } 20 | 21 | const existing = vim.fn.readfile(dataFile); 22 | 23 | if (existing.length === 0) { 24 | return null; 25 | } 26 | 27 | const cache: Cache = vim.fn.json_decode(existing[0]); 28 | 29 | return cache; 30 | }; 31 | 32 | /** Persists the selected projects to disk. Project selection is scoped 33 | * to project directory. */ 34 | export const saveCache = (cache: Cache) => { 35 | logger('debug', 'Saving cache', dataFile); 36 | 37 | vim.fn.writefile([vim.fn.json_encode(cache)], dataFile); 38 | }; 39 | 40 | export const loadProjectCache = (): ProjectCache | null => { 41 | const cache = loadCache(); 42 | 43 | if (cache === null) { 44 | return null; 45 | } 46 | 47 | const projectCache = cache[current] ?? null; 48 | 49 | logger('debug', 'Loading Project Cache', projectCache); 50 | 51 | return projectCache; 52 | }; 53 | 54 | export const saveProjectCache = (latest: ProjectCache) => { 55 | logger('debug', 'Saving Project Cache', latest); 56 | 57 | const cache = loadCache() ?? {}; 58 | 59 | cache[current] = latest; 60 | 61 | saveCache(cache); 62 | }; 63 | -------------------------------------------------------------------------------- /scripts/fix-require-paths.ts: -------------------------------------------------------------------------------- 1 | import MagicString from 'magic-string'; 2 | import fs from 'node:fs'; 3 | import glob from 'tiny-glob'; 4 | 5 | const NO_REPLACE = [ 6 | 'neotest', 7 | 'neotest.async', 8 | 'neotest.lib', 9 | 'neotest.logging', 10 | 'say', 11 | 'telescope.actions', 12 | 'telescope.actions.state', 13 | 'telescope.actions.utils', 14 | 'telescope.config', 15 | 'telescope.finders', 16 | 'telescope.pickers', 17 | ]; 18 | 19 | const PREFIX = 'neotest-playwright'; 20 | 21 | const transform = (file: string) => { 22 | const contents = fs.readFileSync(file, 'utf-8'); 23 | 24 | const magicString = new MagicString(contents); 25 | 26 | // look for all require statements, if they are in the NO_REPLACE array, don't replace them 27 | // else, add the prefix to them like so: require('neotest-playwright.submodule') 28 | magicString.replace(/require\(["']([^"']+)["']\)/g, (match, p1: string) => { 29 | // skip imports that are already prefixed either by tstl or previous runs 30 | // of this script 31 | const alreadyPrefixed = p1.startsWith(PREFIX); 32 | 33 | if (NO_REPLACE.includes(p1) || alreadyPrefixed) { 34 | console.log(`[FIX-PATHS] [SKIP] ${p1}`); 35 | return match; 36 | } 37 | 38 | console.log(`[FIX-PATHS] [ FIX] ${p1} -> ${PREFIX}.${p1}`); 39 | return `require('${PREFIX}.${p1}')`; 40 | }); 41 | 42 | // Save 43 | fs.writeFileSync(file, magicString.toString()); 44 | }; 45 | 46 | const main = async () => { 47 | // match all files in the src/dto folder, and transform them into the ./converted folder 48 | const files = await glob('./lua/**/*.lua'); 49 | 50 | files.forEach((file) => { 51 | transform(file); 52 | }); 53 | }; 54 | 55 | main() 56 | .catch(console.error) 57 | .finally(() => process.exit(0)); 58 | -------------------------------------------------------------------------------- /src/init.ts: -------------------------------------------------------------------------------- 1 | import type { Adapter } from 'neotest'; 2 | import { options } from './adapter-options'; 3 | import { create_refresh_command } from './commands'; 4 | import { config } from './config'; 5 | import { logger } from './logging'; 6 | import { create_preset_command } from './preset'; 7 | import { create_project_command, loadPreselectedProjects } from './project'; 8 | 9 | // Initialize the adapter 10 | create_preset_command(); 11 | create_project_command(); 12 | create_refresh_command(); 13 | 14 | export const adapter = config; 15 | 16 | setmetatable(adapter, { 17 | __call(arg: unknown) { 18 | logger('debug', 'config', arg); 19 | 20 | let userOptions = {}; 21 | // @ts-expect-error wip 22 | if (arg && type(arg) === 'table' && 'options' in arg) { 23 | userOptions = arg.options ?? {}; 24 | } 25 | 26 | const updated = { 27 | ...config.options, 28 | ...userOptions, 29 | }; 30 | 31 | // Apply user config 32 | for (const [key, value] of pairs(updated)) { 33 | if (key === 'filter_dir') { 34 | const filter_dir = value as Adapter["filter_dir"] 35 | // @ts-expect-error filter_dir optionally defined by users should 36 | // override the adapter's own filter_dir 37 | config.filter_dir = filter_dir; 38 | continue; 39 | } 40 | 41 | if (key === 'is_test_file') { 42 | const is_test_file = value as Adapter["is_test_file"] 43 | // @ts-expect-error is_test_file optionally defined by users should 44 | // override the adapter's own is_test_file 45 | config.is_test_file = is_test_file; 46 | continue; 47 | } 48 | 49 | // @ts-expect-error wip 50 | config.options[key] = value; 51 | } 52 | 53 | if (options.persist_project_selection) { 54 | const projects = loadPreselectedProjects(); 55 | if (projects) { 56 | options.projects = projects; 57 | } 58 | } 59 | 60 | logger('debug', 'options', options); 61 | 62 | return adapter; 63 | }, 64 | }); 65 | -------------------------------------------------------------------------------- /src/types/adapter.ts: -------------------------------------------------------------------------------- 1 | import type * as P from '@playwright/test/reporter'; 2 | import type * as neotest from 'neotest'; 3 | import type { Preset } from 'neotest-playwright/preset-options'; 4 | 5 | export interface AdapterOptions { 6 | preset: Preset; 7 | projects: string[]; 8 | persist_project_selection: boolean; 9 | /** Given a test file path, return the path to the playwright binary. */ 10 | get_playwright_binary: (this: void) => string; 11 | /** Given a test file path, return the path to the playwright config file. */ 12 | get_playwright_config: (this: void) => string | null; 13 | /** Environment variables to pass to the playwright command. */ 14 | env: Record; 15 | get_cwd: null | ((this: void) => string); 16 | /** Extra arguments to pass to the playwright command. These are merged with 17 | * any extra_args passed to the neotest run command. */ 18 | extra_args: string[]; 19 | tempDataFile: string; 20 | /** If true, the adapter will attempt to use the playwright cli to 21 | * enhance the test discovery process. */ 22 | enable_dynamic_test_discovery: boolean; 23 | /** Override the default filter_dir function. */ 24 | filter_dir?: Adapter['filter_dir']; 25 | /** Override the default is_test_file function. */ 26 | is_test_file?: Adapter['is_test_file']; 27 | experimental: { 28 | telescope: { 29 | /** If true, a telescope picker will be used for project selection. Otherwise, 30 | * `vim.ui.select` is used. */ 31 | enabled: boolean; 32 | opts: Record; 33 | }; 34 | }; 35 | } 36 | 37 | export type AdapterData = 38 | | { 39 | projects: string[]; 40 | report: P.JSONReport; 41 | specs: P.JSONReportSpec[]; 42 | rootDir: string; 43 | } 44 | | { 45 | projects: null; 46 | report: null; 47 | specs: null; 48 | rootDir: null; 49 | }; 50 | 51 | export interface Adapter extends neotest.Adapter { 52 | options: AdapterOptions; 53 | data: AdapterData; 54 | } 55 | -------------------------------------------------------------------------------- /src/build-command.ts: -------------------------------------------------------------------------------- 1 | import { logger } from './logging'; 2 | 3 | export interface CommandOptions { 4 | bin: string; 5 | debug?: boolean; 6 | headed?: boolean; 7 | retries?: number; 8 | abortOnFailure?: boolean; 9 | workers?: number; 10 | timeout?: number; 11 | config?: string | null; 12 | reporters?: string[]; 13 | projects?: string[]; 14 | testFilter?: string; 15 | } 16 | 17 | export type CommandOptionsPreset = Omit; 18 | 19 | /** A function that takes in CommandOptions and returns a string. */ 20 | export const buildCommand = (options: CommandOptions, extraArgs: string[]) => { 21 | const o = options; 22 | const reporters = o.reporters ?? ['list', 'json']; 23 | const reportersArg = buildReporters(reporters); 24 | 25 | const command: string[] = []; 26 | 27 | command.push(o.bin); 28 | command.push('test'); 29 | if (reportersArg !== null) command.push(reportersArg); 30 | if (o.debug === true) command.push('--debug'); 31 | if (o.headed === true) command.push('--headed'); 32 | if (o.retries !== undefined) command.push(`--retries=${o.retries}`); 33 | if (o.abortOnFailure === true) command.push('-x'); 34 | if (o.workers !== undefined) command.push(`--workers=${o.workers}`); 35 | if (o.timeout !== undefined) command.push(`--timeout=${o.timeout}`); 36 | if (o.config !== undefined) command.push(`--config=${o.config}`); 37 | if (o.projects !== undefined) { 38 | for (const project of o.projects) { 39 | if (typeof project === 'string' && project.length > 0) { 40 | command.push(`--project=${project}`); 41 | } 42 | } 43 | } 44 | command.push(...extraArgs); 45 | if (o.testFilter !== undefined) command.push(o.testFilter); 46 | 47 | logger('debug', 'command', command); 48 | 49 | return command; 50 | }; 51 | 52 | /** Returns `--reporter=${reporters[0]},${reporters[1]},...` */ 53 | const buildReporters = (reporters: string[]) => { 54 | if (reporters.length === 0) { 55 | return null; 56 | } else { 57 | return `--reporter=${reporters.join(',')}`; 58 | } 59 | }; 60 | -------------------------------------------------------------------------------- /src/select-multiple.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Uses vim.ui.select to present a list of choices to the user. However, 3 | * instead of disappearing when the user selects an option, the list remains 4 | * open and the user can select multiple choices. The user can *keep toggling 5 | * choices until they are satisfied with their selection. Then they can press 6 | * enter to close the list and return the selected choices. 7 | * 8 | * An asterisk is used to indicate that an option is selected. 9 | * 10 | * A final option "done" is added to the list to allow the user to close the list. 11 | */ 12 | export const selectMultiple = ({ 13 | prompt, 14 | choices, 15 | initial = 'none', 16 | preselected, 17 | }: { 18 | prompt: string; 19 | 20 | choices: string[]; 21 | 22 | /** Whether to select all choices by default. Ignored if `preselected` is a 23 | * non-null array. */ 24 | initial?: 'all' | 'none'; 25 | 26 | /** An array of choices to select by default. If this is a non-null array, 27 | * then the `initial` option is ignored. */ 28 | preselected: string[] | null; 29 | }) => { 30 | const selected = determineInitialSelection(initial, choices, preselected); 31 | let choice: unknown; 32 | 33 | let done = false as boolean; 34 | 35 | while (!done) { 36 | vim.ui.select( 37 | choices, 38 | { 39 | prompt, 40 | format_item: (item: string) => { 41 | return selected.has(item) ? `* ${item}` : item; 42 | }, 43 | }, 44 | (c) => { 45 | choice = c; 46 | }, 47 | ); 48 | 49 | const index = choices.indexOf(choice as string); 50 | done = index === -1; 51 | 52 | if (done) { 53 | // user aborted the dialog 54 | break; 55 | } else if (selected.has(choice)) { 56 | selected.delete(choice); 57 | } else { 58 | selected.add(choice); 59 | } 60 | 61 | // redraw the screen to avoid stacking multiple dialogs 62 | vim.cmd('redraw'); 63 | } 64 | 65 | return Array.from(selected); 66 | }; 67 | 68 | const determineInitialSelection = ( 69 | initial: string, 70 | choices: string[], 71 | preselected: string[] | null, 72 | ) => { 73 | if (preselected) { 74 | return new Set(preselected); 75 | } else if (initial === 'all') { 76 | return new Set(choices); 77 | } else { 78 | return new Set(); 79 | } 80 | }; 81 | -------------------------------------------------------------------------------- /src/build-spec.ts: -------------------------------------------------------------------------------- 1 | import type { CommandOptions } from 'neotest-playwright/build-command'; 2 | import { buildCommand } from 'neotest-playwright/build-command'; 3 | import * as async from 'neotest.async'; 4 | import { options } from './adapter-options'; 5 | import { COMMAND_PRESETS } from './preset-options'; 6 | import type { Adapter } from './types/adapter'; 7 | 8 | export const buildSpec: Adapter['build_spec'] = (args) => { 9 | const pos = args.tree.data(); 10 | 11 | // playwright supports running tests by line number: file.spec.ts:123 12 | 13 | let testFilter: string; 14 | 15 | if (pos.type === 'dir' || pos.type === 'file') { 16 | testFilter = pos.path; 17 | } else { 18 | let line: number; 19 | 20 | if ('range' in pos) { 21 | line = pos.range[0] + 1; 22 | } else { 23 | // This is a range-less position. To get the correct test filter, we 24 | // need to find the nearest test position with a non-null range. 25 | // https://github.com/nvim-neotest/neotest/pull/172 26 | const range = args.tree.closest_value_for('range') as Range; 27 | line = range[0] + 1; 28 | } 29 | 30 | testFilter = `${pos.path}:${line}`; 31 | } 32 | 33 | const projects = pos.project_id ? [pos.project_id] : options.projects; 34 | 35 | const commandOptions: CommandOptions = { 36 | ...COMMAND_PRESETS[options.preset], 37 | bin: options.get_playwright_binary(), 38 | config: options.get_playwright_config(), 39 | projects, 40 | testFilter: testFilter, 41 | }; 42 | 43 | const resultsPath = `${async.fn.tempname()}.json`; 44 | 45 | const extraArgs = getExtraArgs(args.extra_args, options.extra_args); 46 | 47 | return { 48 | command: buildCommand(commandOptions, extraArgs), 49 | cwd: typeof options.get_cwd === 'function' ? options.get_cwd() : null, 50 | context: { 51 | results_path: resultsPath, 52 | file: pos.path, 53 | }, 54 | // strategy: getStrategyConfig( 55 | // getDefaultStrategyConfig(args.strategy, command, cwd) || {}, 56 | // args, 57 | // ), 58 | env: { 59 | PLAYWRIGHT_JSON_OUTPUT_NAME: resultsPath, 60 | ...options.env, 61 | }, 62 | }; 63 | }; 64 | 65 | const getExtraArgs = (...args: (string[] | undefined)[]): string[] => { 66 | const extraArgs = args.filter((arg) => arg !== undefined) as string[][]; 67 | return ([] as string[]).concat(...extraArgs); 68 | }; 69 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | /** @type {import("eslint").Linter.Config} */ 2 | 3 | // eslint-disable-next-line no-undef 4 | module.exports = { 5 | root: true, 6 | globals: { 7 | 'module':'off' 8 | }, 9 | plugins: ['@typescript-eslint'], 10 | extends: [ 11 | 'eslint:recommended', 12 | 'plugin:@typescript-eslint/eslint-recommended', 13 | 'plugin:@typescript-eslint/recommended', 14 | 'plugin:@typescript-eslint/recommended-requiring-type-checking', 15 | 'plugin:@typescript-eslint/strict', 16 | 17 | // https://mysticatea.github.io/eslint-plugin-eslint-comments/ 18 | 'plugin:eslint-comments/recommended', 19 | ], 20 | parser: '@typescript-eslint/parser', 21 | parserOptions: { 22 | project: './tsconfig.json', 23 | }, 24 | rules: { 25 | // https://typescripttolua.github.io/docs/the-self-parameter#removing-it 26 | '@typescript-eslint/no-invalid-void-type': 'off', 27 | 28 | 'no-unused-vars': 'off', 29 | '@typescript-eslint/no-unused-vars': [ 30 | 'error', 31 | { 32 | destructuredArrayIgnorePattern: '^_', 33 | ignoreRestSiblings: true, 34 | argsIgnorePattern: '^_', 35 | }, 36 | ], 37 | 38 | // https://eslint.org/docs/latest/rules/prefer-const#options 39 | 'prefer-const': [ 40 | 'error', 41 | { destructuring: 'all', ignoreReadBeforeAssign: false }, 42 | ], 43 | 44 | '@typescript-eslint/restrict-template-expressions': [ 45 | 'error', 46 | { allowBoolean: true }, 47 | ], 48 | 49 | // Better stack traces (at the cost of a bit of performance) 50 | 'no-return-await': 'off', 51 | '@typescript-eslint/return-await': ['warn', 'always'], 52 | 53 | '@typescript-eslint/promise-function-async': 'warn', 54 | 55 | '@typescript-eslint/ban-ts-comment': 'off', 56 | '@typescript-eslint/prefer-ts-expect-error': 'off', 57 | 58 | '@typescript-eslint/consistent-type-exports': 'error', 59 | '@typescript-eslint/consistent-type-imports': [ 60 | 'error', 61 | // we need this for the declaration files (*.d.ts) 62 | { disallowTypeAnnotations: false }, 63 | ], 64 | 65 | '@typescript-eslint/prefer-readonly': 'warn', 66 | '@typescript-eslint/member-ordering': 'warn', 67 | '@typescript-eslint/require-array-sort-compare': 'warn', 68 | '@typescript-eslint/prefer-regexp-exec': 'warn', 69 | '@typescript-eslint/switch-exhaustiveness-check': 'warn', 70 | 71 | 'no-mixed-spaces-and-tabs': ['error', 'smart-tabs'], 72 | 73 | 'eslint-comments/disable-enable-pair': 'off', 74 | 'eslint-comments/no-unused-disable': 'error', 75 | }, 76 | }; 77 | -------------------------------------------------------------------------------- /lua/neotest-playwright/build-command.lua: -------------------------------------------------------------------------------- 1 | --[[ Generated with https://github.com/TypeScriptToLua/TypeScriptToLua ]] 2 | -- Lua Library inline imports 3 | local function __TS__ArrayPushArray(self, items) 4 | local len = #self 5 | for i = 1, #items do 6 | len = len + 1 7 | self[len] = items[i] 8 | end 9 | return len 10 | end 11 | -- End of Lua Library inline imports 12 | local ____exports = {} 13 | local buildReporters 14 | local ____logging = require('neotest-playwright.logging') 15 | local logger = ____logging.logger 16 | --- A function that takes in CommandOptions and returns a string. 17 | ____exports.buildCommand = function(options, extraArgs) 18 | local o = options 19 | local reporters = o.reporters or ({"list", "json"}) 20 | local reportersArg = buildReporters(reporters) 21 | local command = {} 22 | command[#command + 1] = o.bin 23 | command[#command + 1] = "test" 24 | if reportersArg ~= nil then 25 | command[#command + 1] = reportersArg 26 | end 27 | if o.debug == true then 28 | command[#command + 1] = "--debug" 29 | end 30 | if o.headed == true then 31 | command[#command + 1] = "--headed" 32 | end 33 | if o.retries ~= nil then 34 | command[#command + 1] = "--retries=" .. tostring(o.retries) 35 | end 36 | if o.abortOnFailure == true then 37 | command[#command + 1] = "-x" 38 | end 39 | if o.workers ~= nil then 40 | command[#command + 1] = "--workers=" .. tostring(o.workers) 41 | end 42 | if o.timeout ~= nil then 43 | command[#command + 1] = "--timeout=" .. tostring(o.timeout) 44 | end 45 | if o.config ~= nil then 46 | command[#command + 1] = "--config=" .. tostring(o.config) 47 | end 48 | if o.projects ~= nil then 49 | for ____, project in ipairs(o.projects) do 50 | if type(project) == "string" and #project > 0 then 51 | command[#command + 1] = "--project=" .. project 52 | end 53 | end 54 | end 55 | __TS__ArrayPushArray(command, extraArgs) 56 | if o.testFilter ~= nil then 57 | command[#command + 1] = o.testFilter 58 | end 59 | logger("debug", "command", command) 60 | return command 61 | end 62 | --- Returns `--reporter=${reporters[0]},${reporters[1]},...` 63 | buildReporters = function(reporters) 64 | if #reporters == 0 then 65 | return nil 66 | else 67 | return "--reporter=" .. table.concat(reporters, ",") 68 | end 69 | end 70 | return ____exports 71 | -------------------------------------------------------------------------------- /src/project.ts: -------------------------------------------------------------------------------- 1 | import type * as P from '@playwright/test/reporter'; 2 | import { show_picker } from 'neotest-playwright.pickers'; 3 | import { options } from './adapter-options'; 4 | import { logger } from './logging'; 5 | import { loadProjectCache, saveProjectCache } from './persist'; 6 | import { get_config } from './playwright'; 7 | import { selectMultiple } from './select-multiple'; 8 | 9 | // TODO: document interaction with dynamic test discovery 10 | 11 | /** Returns a list of project names */ 12 | const parseProjects = (output: P.JSONReport) => { 13 | const names = output.config.projects.map((p) => p.name); 14 | 15 | return names; 16 | }; 17 | 18 | /** Returns a list of project names from the cached data. */ 19 | export const loadPreselectedProjects = () => { 20 | const cache = loadProjectCache(); 21 | 22 | if (cache) { 23 | return cache.projects; 24 | } else { 25 | return null; 26 | } 27 | }; 28 | 29 | export const create_project_command = () => { 30 | vim.api.nvim_create_user_command( 31 | 'NeotestPlaywrightProject', 32 | // @ts-expect-error until type is updated 33 | () => { 34 | const output = get_config(); 35 | 36 | const choices = parseProjects(output); 37 | 38 | let preselected: string[] = []; 39 | 40 | // if options.persist_project_selection is false, avoid loading from cache 41 | // even if it exists 42 | if (options.persist_project_selection) { 43 | preselected = loadPreselectedProjects() ?? []; 44 | } 45 | 46 | selectProjects(choices, preselected, (selection) => { 47 | setProjects(selection); 48 | 49 | logger('info', 'selectProjects', selection); 50 | 51 | // trigger data refresh in subprocess 52 | vim.api.nvim_command('NeotestPlaywrightRefresh'); 53 | }); 54 | }, 55 | { 56 | nargs: 0, 57 | }, 58 | ); 59 | }; 60 | 61 | const selectProjects = ( 62 | choices: string[], 63 | preselected: string[], 64 | on_select: (selection: string[]) => void, 65 | use_telescope = options.experimental.telescope.enabled, 66 | telescope_opts = options.experimental.telescope.opts, 67 | ) => { 68 | const prompt = 69 | '(toggle: , apply: ) Select projects to include in the next test run. Applying selection will also clear out any phantom projects.'; 70 | 71 | if (use_telescope) { 72 | show_picker(telescope_opts, { 73 | prompt: prompt, 74 | choices: choices, 75 | preselected: preselected, 76 | on_select: (selection) => on_select(selection), 77 | }); 78 | } else { 79 | const choice = selectMultiple({ 80 | prompt, 81 | choices, 82 | initial: 'all', 83 | preselected, 84 | }); 85 | on_select(choice as string[]); 86 | } 87 | }; 88 | 89 | const setProjects = (projects: string[]) => { 90 | logger('debug', 'setProjects', projects); 91 | 92 | if (options.persist_project_selection) { 93 | saveProjectCache({ projects }); 94 | } 95 | 96 | options.projects = projects; 97 | }; 98 | -------------------------------------------------------------------------------- /lua/neotest-playwright/init.lua: -------------------------------------------------------------------------------- 1 | --[[ Generated with https://github.com/TypeScriptToLua/TypeScriptToLua ]] 2 | -- Lua Library inline imports 3 | local function __TS__ObjectAssign(target, ...) 4 | local sources = {...} 5 | for i = 1, #sources do 6 | local source = sources[i] 7 | for key in pairs(source) do 8 | target[key] = source[key] 9 | end 10 | end 11 | return target 12 | end 13 | -- End of Lua Library inline imports 14 | local ____exports = {} 15 | local ____adapter_2Doptions = require('neotest-playwright.adapter-options') 16 | local options = ____adapter_2Doptions.options 17 | local ____commands = require('neotest-playwright.commands') 18 | local create_refresh_command = ____commands.create_refresh_command 19 | local ____config = require('neotest-playwright.config') 20 | local config = ____config.config 21 | local ____logging = require('neotest-playwright.logging') 22 | local logger = ____logging.logger 23 | local ____preset = require('neotest-playwright.preset') 24 | local create_preset_command = ____preset.create_preset_command 25 | local ____project = require('neotest-playwright.project') 26 | local create_project_command = ____project.create_project_command 27 | local loadPreselectedProjects = ____project.loadPreselectedProjects 28 | create_preset_command() 29 | create_project_command() 30 | create_refresh_command() 31 | ____exports.adapter = config 32 | setmetatable( 33 | ____exports.adapter, 34 | {__call = function(self, arg) 35 | logger("debug", "config", arg) 36 | local userOptions = {} 37 | if arg and type(arg) == "table" and arg.options ~= nil then 38 | local ____arg_options_0 = arg.options 39 | if ____arg_options_0 == nil then 40 | ____arg_options_0 = {} 41 | end 42 | userOptions = ____arg_options_0 43 | end 44 | local updated = __TS__ObjectAssign({}, config.options, userOptions) 45 | for key, value in pairs(updated) do 46 | do 47 | if key == "filter_dir" then 48 | local filter_dir = value 49 | config.filter_dir = filter_dir 50 | goto __continue4 51 | end 52 | if key == "is_test_file" then 53 | local is_test_file = value 54 | config.is_test_file = is_test_file 55 | goto __continue4 56 | end 57 | config.options[key] = value 58 | end 59 | ::__continue4:: 60 | end 61 | if options.persist_project_selection then 62 | local projects = loadPreselectedProjects() 63 | if projects then 64 | options.projects = projects 65 | end 66 | end 67 | logger("debug", "options", options) 68 | return ____exports.adapter 69 | end} 70 | ) 71 | return ____exports 72 | -------------------------------------------------------------------------------- /src/consumers/attachment.lua: -------------------------------------------------------------------------------- 1 | local async = require('neotest.async') 2 | 3 | local M = {} 4 | 5 | --- @param client neotest.Client 6 | M.attachment = function(client) 7 | local position = require('neotest-playwright.consumers.util').get_pos(client) 8 | if not position then 9 | return 10 | end 11 | 12 | local children_ids = {} 13 | 14 | for _, child in position:iter_nodes() do 15 | -- print("child", vim.inspect(child:data().name)) 16 | table.insert(children_ids, child:data().id) 17 | end 18 | 19 | local pos_id = position:data().id 20 | -- if not results[pos_id] then 21 | -- return 22 | -- end 23 | 24 | local file = async.fn.expand('%:p') 25 | 26 | -- local adapter_name, adapter = client:get_adapter(file) 27 | local adapter_name, adapter = client:get_adapter(file) 28 | if not adapter_name or not adapter then 29 | return 30 | end 31 | 32 | local results = client:get_results(adapter_name) 33 | -- client.listeners.discover_positions 34 | 35 | local attachments = {} 36 | 37 | -- iterate over children_ids, then see if any of them are in results (key is pos_id) 38 | -- if so, add anything result.attachments to attachments 39 | for _, child in position:iter_nodes() do 40 | local result = results[child:data().id] or {} 41 | local result_attachments = result.attachments or {} 42 | for _, attachment in ipairs(result_attachments) do 43 | -- add project_id to attachment, then add to attachments 44 | local data = child:data() 45 | attachment.id = data.id 46 | attachment.project_id = data.project_id 47 | attachment.short = result.short 48 | 49 | table.insert(attachments, attachment) 50 | end 51 | end 52 | 53 | local options = {} 54 | local function option_choice(attachment) 55 | if attachment.name == 'video' then 56 | return string.format('%s %s (%s)', attachment.project_id, attachment.name, attachment.short) 57 | else 58 | return string.format('%s %s', attachment.project_id, attachment.name) 59 | end 60 | end 61 | for _, attachment in ipairs(attachments) do 62 | local option = option_choice(attachment) 63 | table.insert(options, option) 64 | end 65 | 66 | if #options == 0 then 67 | print('No attachments found') 68 | return 69 | end 70 | 71 | vim.ui.select(options, { prompt = 'Select an attachment:' }, function(choice, i) 72 | if not choice then 73 | return 74 | end 75 | 76 | local selection = attachments[i] 77 | if not selection then 78 | return 79 | end 80 | 81 | local xdg_content_types = { 'video/webm', 'image/png' } 82 | 83 | if selection.contentType == 'application/zip' then 84 | local bin = adapter.options.get_playwright_binary(file) 85 | local cmd = bin .. ' show-trace ' .. selection.path .. ' &' 86 | os.execute(cmd) 87 | elseif selection.contentType == 'text/plain' then 88 | vim.cmd.edit(selection.path) 89 | elseif vim.tbl_contains(xdg_content_types, selection.contentType) then 90 | local cmd = 'xdg-open ' .. selection.path .. ' &' 91 | os.execute(cmd) 92 | end 93 | end) 94 | end 95 | 96 | return M 97 | -------------------------------------------------------------------------------- /lua/neotest-playwright/consumers/attachment.lua: -------------------------------------------------------------------------------- 1 | local async = require('neotest.async') 2 | 3 | local M = {} 4 | 5 | --- @param client neotest.Client 6 | M.attachment = function(client) 7 | local position = require('neotest-playwright.consumers.util').get_pos(client) 8 | if not position then 9 | return 10 | end 11 | 12 | local children_ids = {} 13 | 14 | for _, child in position:iter_nodes() do 15 | -- print("child", vim.inspect(child:data().name)) 16 | table.insert(children_ids, child:data().id) 17 | end 18 | 19 | local pos_id = position:data().id 20 | -- if not results[pos_id] then 21 | -- return 22 | -- end 23 | 24 | local file = async.fn.expand('%:p') 25 | 26 | -- local adapter_name, adapter = client:get_adapter(file) 27 | local adapter_name, adapter = client:get_adapter(file) 28 | if not adapter_name or not adapter then 29 | return 30 | end 31 | 32 | local results = client:get_results(adapter_name) 33 | -- client.listeners.discover_positions 34 | 35 | local attachments = {} 36 | 37 | -- iterate over children_ids, then see if any of them are in results (key is pos_id) 38 | -- if so, add anything result.attachments to attachments 39 | for _, child in position:iter_nodes() do 40 | local result = results[child:data().id] or {} 41 | local result_attachments = result.attachments or {} 42 | for _, attachment in ipairs(result_attachments) do 43 | -- add project_id to attachment, then add to attachments 44 | local data = child:data() 45 | attachment.id = data.id 46 | attachment.project_id = data.project_id 47 | attachment.short = result.short 48 | 49 | table.insert(attachments, attachment) 50 | end 51 | end 52 | 53 | local options = {} 54 | local function option_choice(attachment) 55 | if attachment.name == 'video' then 56 | return string.format('%s %s (%s)', attachment.project_id, attachment.name, attachment.short) 57 | else 58 | return string.format('%s %s', attachment.project_id, attachment.name) 59 | end 60 | end 61 | for _, attachment in ipairs(attachments) do 62 | local option = option_choice(attachment) 63 | table.insert(options, option) 64 | end 65 | 66 | if #options == 0 then 67 | print('No attachments found') 68 | return 69 | end 70 | 71 | vim.ui.select(options, { prompt = 'Select an attachment:' }, function(choice, i) 72 | if not choice then 73 | return 74 | end 75 | 76 | local selection = attachments[i] 77 | if not selection then 78 | return 79 | end 80 | 81 | local xdg_content_types = { 'video/webm', 'image/png' } 82 | 83 | if selection.contentType == 'application/zip' then 84 | local bin = adapter.options.get_playwright_binary(file) 85 | local cmd = bin .. ' show-trace ' .. selection.path .. ' &' 86 | os.execute(cmd) 87 | elseif selection.contentType == 'text/plain' then 88 | vim.cmd.edit(selection.path) 89 | elseif vim.tbl_contains(xdg_content_types, selection.contentType) then 90 | local cmd = 'xdg-open ' .. selection.path .. ' &' 91 | os.execute(cmd) 92 | end 93 | end) 94 | end 95 | 96 | return M 97 | -------------------------------------------------------------------------------- /src/position.ts: -------------------------------------------------------------------------------- 1 | import type * as P from '@playwright/test/reporter'; 2 | import type { Position, RangedPosition, RangelessPosition } from 'neotest'; 3 | import { data } from './adapter-data'; 4 | import { options } from './adapter-options'; 5 | import { emitError } from './helpers'; 6 | import { logger } from './logging'; 7 | 8 | type BasePosition = Omit; 9 | 10 | /** Given a test position, return one or more positions based on what can be 11 | * dynamically discovered using the playwright cli. */ 12 | export const buildTestPosition = (basePosition: BasePosition): Position[] => { 13 | const line = basePosition.range[0]; 14 | // const column = position.range[1]; 15 | 16 | if (!data.specs) { 17 | throw new Error('No specs found'); 18 | } 19 | 20 | const specs = data.specs.filter((spec) => { 21 | const specAbsolutePath = data.rootDir + '/' + spec.file; 22 | 23 | const fileMatch = specAbsolutePath === basePosition.path; 24 | 25 | if (!fileMatch) { 26 | return false; 27 | } 28 | 29 | const rowMatch = spec.line === line + 1; 30 | // const columnMatch = spec.column === column + 1; 31 | 32 | const match = rowMatch && fileMatch; 33 | 34 | return match; 35 | }); 36 | 37 | if (specs.length === 0) { 38 | logger('debug', 'No match found'); 39 | 40 | // return position with available data 41 | return [basePosition]; 42 | } 43 | 44 | // filter out positions belonging to ignored projects 45 | const projects = options.projects; 46 | 47 | const positions: Position[] = []; 48 | 49 | /** The parent of the range-less positions */ 50 | const main = { 51 | ...basePosition, 52 | // TODO: use treesitter id? 53 | } satisfies Position; 54 | 55 | positions.push(main); 56 | 57 | specs.map((spec) => { 58 | const position = specToPosition(spec, basePosition); 59 | 60 | // Determine if the position is part of the selected projects. 61 | 62 | if (options.projects.length === 0) { 63 | // No projects specified, so all positions are selected 64 | positions.push(position); 65 | return; 66 | } else { 67 | const projectId = position.project_id; 68 | 69 | if (!projectId) { 70 | const msg = `No project id found for position: ${position.name}`; 71 | emitError(msg); 72 | throw new Error(msg); 73 | } 74 | 75 | if (projects.includes(projectId)) { 76 | positions.push(position); 77 | } 78 | } 79 | }); 80 | 81 | return positions; 82 | }; 83 | 84 | /** Convert a playwright spec to a neotest position. */ 85 | const specToPosition = ( 86 | spec: P.JSONReportSpec, 87 | basePosition: BasePosition, 88 | ): RangelessPosition | RangedPosition => { 89 | const projectId = spec.tests[0]?.projectName; 90 | 91 | if (!projectId) { 92 | const msg = `No project id found for spec: ${spec.title}`; 93 | emitError(msg); 94 | throw new Error(msg); 95 | } 96 | 97 | const { range, ...rest } = basePosition; 98 | const position = { 99 | ...rest, 100 | id: spec.id, 101 | name: projectId, 102 | project_id: projectId, 103 | }; 104 | 105 | return position; 106 | }; 107 | -------------------------------------------------------------------------------- /lua/neotest-playwright/build-spec.lua: -------------------------------------------------------------------------------- 1 | --[[ Generated with https://github.com/TypeScriptToLua/TypeScriptToLua ]] 2 | -- Lua Library inline imports 3 | local function __TS__ObjectAssign(target, ...) 4 | local sources = {...} 5 | for i = 1, #sources do 6 | local source = sources[i] 7 | for key in pairs(source) do 8 | target[key] = source[key] 9 | end 10 | end 11 | return target 12 | end 13 | 14 | local function __TS__ArrayFilter(self, callbackfn, thisArg) 15 | local result = {} 16 | local len = 0 17 | for i = 1, #self do 18 | if callbackfn(thisArg, self[i], i - 1, self) then 19 | len = len + 1 20 | result[len] = self[i] 21 | end 22 | end 23 | return result 24 | end 25 | 26 | local function __TS__ArrayIsArray(value) 27 | return type(value) == "table" and (value[1] ~= nil or next(value) == nil) 28 | end 29 | 30 | local function __TS__ArrayConcat(self, ...) 31 | local items = {...} 32 | local result = {} 33 | local len = 0 34 | for i = 1, #self do 35 | len = len + 1 36 | result[len] = self[i] 37 | end 38 | for i = 1, #items do 39 | local item = items[i] 40 | if __TS__ArrayIsArray(item) then 41 | for j = 1, #item do 42 | len = len + 1 43 | result[len] = item[j] 44 | end 45 | else 46 | len = len + 1 47 | result[len] = item 48 | end 49 | end 50 | return result 51 | end 52 | -- End of Lua Library inline imports 53 | local ____exports = {} 54 | local getExtraArgs 55 | local ____build_2Dcommand = require('neotest-playwright.build-command') 56 | local buildCommand = ____build_2Dcommand.buildCommand 57 | local async = require("neotest.async") 58 | local ____adapter_2Doptions = require('neotest-playwright.adapter-options') 59 | local options = ____adapter_2Doptions.options 60 | local ____preset_2Doptions = require('neotest-playwright.preset-options') 61 | local COMMAND_PRESETS = ____preset_2Doptions.COMMAND_PRESETS 62 | ____exports.buildSpec = function(args) 63 | local pos = args.tree:data() 64 | local testFilter 65 | if pos.type == "dir" or pos.type == "file" then 66 | testFilter = pos.path 67 | else 68 | local line 69 | if pos.range ~= nil then 70 | line = pos.range[1] + 1 71 | else 72 | local range = args.tree:closest_value_for("range") 73 | line = range[1] + 1 74 | end 75 | testFilter = (pos.path .. ":") .. tostring(line) 76 | end 77 | local projects = pos.project_id and ({pos.project_id}) or options.projects 78 | local commandOptions = __TS__ObjectAssign( 79 | {}, 80 | COMMAND_PRESETS[options.preset], 81 | { 82 | bin = options.get_playwright_binary(), 83 | config = options.get_playwright_config(), 84 | projects = projects, 85 | testFilter = testFilter 86 | } 87 | ) 88 | local resultsPath = async.fn.tempname() .. ".json" 89 | local extraArgs = getExtraArgs(args.extra_args, options.extra_args) 90 | return { 91 | command = buildCommand(commandOptions, extraArgs), 92 | cwd = type(options.get_cwd) == "function" and options.get_cwd() or nil, 93 | context = {results_path = resultsPath, file = pos.path}, 94 | env = __TS__ObjectAssign({PLAYWRIGHT_JSON_OUTPUT_NAME = resultsPath}, options.env) 95 | } 96 | end 97 | getExtraArgs = function(...) 98 | local args = {...} 99 | local extraArgs = __TS__ArrayFilter( 100 | args, 101 | function(____, arg) return arg ~= nil end 102 | ) 103 | return __TS__ArrayConcat( 104 | {}, 105 | unpack(extraArgs) 106 | ) 107 | end 108 | return ____exports 109 | -------------------------------------------------------------------------------- /lua/neotest-playwright/project.lua: -------------------------------------------------------------------------------- 1 | --[[ Generated with https://github.com/TypeScriptToLua/TypeScriptToLua ]] 2 | -- Lua Library inline imports 3 | local function __TS__ArrayMap(self, callbackfn, thisArg) 4 | local result = {} 5 | for i = 1, #self do 6 | result[i] = callbackfn(thisArg, self[i], i - 1, self) 7 | end 8 | return result 9 | end 10 | -- End of Lua Library inline imports 11 | local ____exports = {} 12 | local selectProjects, setProjects 13 | local ____neotest_2Dplaywright_2Epickers = require("neotest-playwright.pickers") 14 | local show_picker = ____neotest_2Dplaywright_2Epickers.show_picker 15 | local ____adapter_2Doptions = require('neotest-playwright.adapter-options') 16 | local options = ____adapter_2Doptions.options 17 | local ____logging = require('neotest-playwright.logging') 18 | local logger = ____logging.logger 19 | local ____persist = require('neotest-playwright.persist') 20 | local loadProjectCache = ____persist.loadProjectCache 21 | local saveProjectCache = ____persist.saveProjectCache 22 | local ____playwright = require('neotest-playwright.playwright') 23 | local get_config = ____playwright.get_config 24 | local ____select_2Dmultiple = require('neotest-playwright.select-multiple') 25 | local selectMultiple = ____select_2Dmultiple.selectMultiple 26 | --- Returns a list of project names 27 | local function parseProjects(output) 28 | local names = __TS__ArrayMap( 29 | output.config.projects, 30 | function(____, p) return p.name end 31 | ) 32 | return names 33 | end 34 | --- Returns a list of project names from the cached data. 35 | ____exports.loadPreselectedProjects = function() 36 | local cache = loadProjectCache() 37 | if cache then 38 | return cache.projects 39 | else 40 | return nil 41 | end 42 | end 43 | ____exports.create_project_command = function() 44 | vim.api.nvim_create_user_command( 45 | "NeotestPlaywrightProject", 46 | function() 47 | local output = get_config() 48 | local choices = parseProjects(output) 49 | local preselected = {} 50 | if options.persist_project_selection then 51 | preselected = ____exports.loadPreselectedProjects() or ({}) 52 | end 53 | selectProjects( 54 | choices, 55 | preselected, 56 | function(selection) 57 | setProjects(selection) 58 | logger("info", "selectProjects", selection) 59 | vim.api.nvim_command("NeotestPlaywrightRefresh") 60 | end 61 | ) 62 | end, 63 | {nargs = 0} 64 | ) 65 | end 66 | selectProjects = function(choices, preselected, on_select, use_telescope, telescope_opts) 67 | if use_telescope == nil then 68 | use_telescope = options.experimental.telescope.enabled 69 | end 70 | if telescope_opts == nil then 71 | telescope_opts = options.experimental.telescope.opts 72 | end 73 | local prompt = "(toggle: , apply: ) Select projects to include in the next test run. Applying selection will also clear out any phantom projects." 74 | if use_telescope then 75 | show_picker( 76 | telescope_opts, 77 | { 78 | prompt = prompt, 79 | choices = choices, 80 | preselected = preselected, 81 | on_select = function(selection) return on_select(selection) end 82 | } 83 | ) 84 | else 85 | local choice = selectMultiple({prompt = prompt, choices = choices, initial = "all", preselected = preselected}) 86 | on_select(choice) 87 | end 88 | end 89 | setProjects = function(projects) 90 | logger("debug", "setProjects", projects) 91 | if options.persist_project_selection then 92 | saveProjectCache({projects = projects}) 93 | end 94 | options.projects = projects 95 | end 96 | return ____exports 97 | -------------------------------------------------------------------------------- /src/pickers.lua: -------------------------------------------------------------------------------- 1 | local has_telescope, telescope = pcall(require, 'telescope') 2 | 3 | if not has_telescope then 4 | return {} 5 | end 6 | 7 | local finders = require('telescope.finders') 8 | local pickers = require('telescope.pickers') 9 | local conf = require('telescope.config').values 10 | local action_state = require('telescope.actions.state') 11 | local action_utils = require('telescope.actions.utils') 12 | local actions = require('telescope.actions') 13 | 14 | ---@alias ProjectName string 15 | 16 | ---@class NeotestPlaywrightProjectOptions 17 | ---@field prompt string 18 | ---@field choices ProjectName[] 19 | ---@field preselected ProjectName[] 20 | ---@field on_select fun(selected: ProjectName[]) 21 | 22 | local select_some = function(prompt_bufnr, selections) 23 | local current_picker = action_state.get_current_picker(prompt_bufnr) 24 | local selections_set = {} 25 | for _, selection in ipairs(selections) do 26 | selections_set[selection] = true 27 | end 28 | action_utils.map_entries(prompt_bufnr, function(entry, _, row) 29 | if selections_set[entry.value] and not current_picker._multi:is_selected(entry) then 30 | current_picker._multi:add(entry) 31 | if current_picker:can_select_row(row) then 32 | local caret = current_picker:update_prefix(entry, row) 33 | if current_picker._selection_entry == entry and current_picker._selection_row == row then 34 | current_picker.highlighter:hi_selection(row, caret:match('(.*%S)')) 35 | end 36 | current_picker.highlighter:hi_multiselect(row, current_picker._multi:is_selected(entry)) 37 | end 38 | end 39 | end) 40 | current_picker:get_status_updater(current_picker.prompt_win, current_picker.prompt_bufnr)() 41 | end 42 | 43 | ---@param np_opts NeotestPlaywrightProjectOptions 44 | local function show_picker(opts, np_opts) 45 | opts = opts or {} 46 | 47 | -- convert `{'PROJECT1', 'PROJECT2'}` to `{{text='PROJECT1'}, {text='PROJECT2'}}` 48 | local results = vim 49 | .iter(np_opts.choices) 50 | :map(function(x) 51 | return { text = x } 52 | end) 53 | :totable() 54 | 55 | local picker = pickers.new(opts, { 56 | prompt_title = np_opts.prompt, 57 | finder = finders.new_table({ 58 | results = results, 59 | entry_maker = function(entry) 60 | return { 61 | value = entry.text, 62 | display = entry.text, 63 | ordinal = entry.text, 64 | } 65 | end, 66 | }), 67 | sorter = conf.generic_sorter(opts), 68 | attach_mappings = function(prompt_bufnr, map) 69 | local picker = action_state.get_current_picker(prompt_bufnr) 70 | 71 | -- Confirm selection on `` 72 | actions.select_default:replace(function() 73 | ---@type ProjectName[] 74 | local selected = {} 75 | for _, entry in ipairs(picker:get_multi_selection()) do 76 | ---@type ProjectName 77 | local value = entry.value 78 | table.insert(selected, value) 79 | end 80 | np_opts.on_select(selected) 81 | actions.close(prompt_bufnr) 82 | end) 83 | 84 | -- Toggle the selection under the cursor 85 | map('n', '', function() 86 | actions.toggle_selection(prompt_bufnr) 87 | end) 88 | 89 | -- Load preselected projects into the picker 90 | map('n', 'R', function() 91 | select_some(prompt_bufnr, np_opts.preselected) 92 | end) 93 | 94 | return true 95 | end, 96 | }) 97 | picker:find() 98 | 99 | -- Automatically mark the selected options as selected. This function errors 100 | -- when called quickly after creating the picker. Therefore, we delay its 101 | -- execution a bit. 102 | local timer = vim.loop.new_timer() 103 | timer:start( 104 | 70, 105 | 0, 106 | vim.schedule_wrap(function() 107 | timer:stop() 108 | timer:close() 109 | -- `picker.manager` will be false when the picker is first created. We 110 | -- need to wait for it to be initialized before attempting to 111 | -- `select_some()`. 112 | if picker.manager ~= false then 113 | select_some(picker.prompt_bufnr, np_opts.preselected) 114 | end 115 | end) 116 | ) 117 | end 118 | 119 | local M = {} 120 | M.show_picker = show_picker 121 | return M 122 | -------------------------------------------------------------------------------- /lua/neotest-playwright/pickers.lua: -------------------------------------------------------------------------------- 1 | local has_telescope, telescope = pcall(require, 'telescope') 2 | 3 | if not has_telescope then 4 | return {} 5 | end 6 | 7 | local finders = require('telescope.finders') 8 | local pickers = require('telescope.pickers') 9 | local conf = require('telescope.config').values 10 | local action_state = require('telescope.actions.state') 11 | local action_utils = require('telescope.actions.utils') 12 | local actions = require('telescope.actions') 13 | 14 | ---@alias ProjectName string 15 | 16 | ---@class NeotestPlaywrightProjectOptions 17 | ---@field prompt string 18 | ---@field choices ProjectName[] 19 | ---@field preselected ProjectName[] 20 | ---@field on_select fun(selected: ProjectName[]) 21 | 22 | local select_some = function(prompt_bufnr, selections) 23 | local current_picker = action_state.get_current_picker(prompt_bufnr) 24 | local selections_set = {} 25 | for _, selection in ipairs(selections) do 26 | selections_set[selection] = true 27 | end 28 | action_utils.map_entries(prompt_bufnr, function(entry, _, row) 29 | if selections_set[entry.value] and not current_picker._multi:is_selected(entry) then 30 | current_picker._multi:add(entry) 31 | if current_picker:can_select_row(row) then 32 | local caret = current_picker:update_prefix(entry, row) 33 | if current_picker._selection_entry == entry and current_picker._selection_row == row then 34 | current_picker.highlighter:hi_selection(row, caret:match('(.*%S)')) 35 | end 36 | current_picker.highlighter:hi_multiselect(row, current_picker._multi:is_selected(entry)) 37 | end 38 | end 39 | end) 40 | current_picker:get_status_updater(current_picker.prompt_win, current_picker.prompt_bufnr)() 41 | end 42 | 43 | ---@param np_opts NeotestPlaywrightProjectOptions 44 | local function show_picker(opts, np_opts) 45 | opts = opts or {} 46 | 47 | -- convert `{'PROJECT1', 'PROJECT2'}` to `{{text='PROJECT1'}, {text='PROJECT2'}}` 48 | local results = vim 49 | .iter(np_opts.choices) 50 | :map(function(x) 51 | return { text = x } 52 | end) 53 | :totable() 54 | 55 | local picker = pickers.new(opts, { 56 | prompt_title = np_opts.prompt, 57 | finder = finders.new_table({ 58 | results = results, 59 | entry_maker = function(entry) 60 | return { 61 | value = entry.text, 62 | display = entry.text, 63 | ordinal = entry.text, 64 | } 65 | end, 66 | }), 67 | sorter = conf.generic_sorter(opts), 68 | attach_mappings = function(prompt_bufnr, map) 69 | local picker = action_state.get_current_picker(prompt_bufnr) 70 | 71 | -- Confirm selection on `` 72 | actions.select_default:replace(function() 73 | ---@type ProjectName[] 74 | local selected = {} 75 | for _, entry in ipairs(picker:get_multi_selection()) do 76 | ---@type ProjectName 77 | local value = entry.value 78 | table.insert(selected, value) 79 | end 80 | np_opts.on_select(selected) 81 | actions.close(prompt_bufnr) 82 | end) 83 | 84 | -- Toggle the selection under the cursor 85 | map('n', '', function() 86 | actions.toggle_selection(prompt_bufnr) 87 | end) 88 | 89 | -- Load preselected projects into the picker 90 | map('n', 'R', function() 91 | select_some(prompt_bufnr, np_opts.preselected) 92 | end) 93 | 94 | return true 95 | end, 96 | }) 97 | picker:find() 98 | 99 | -- Automatically mark the selected options as selected. This function errors 100 | -- when called quickly after creating the picker. Therefore, we delay its 101 | -- execution a bit. 102 | local timer = vim.loop.new_timer() 103 | timer:start( 104 | 70, 105 | 0, 106 | vim.schedule_wrap(function() 107 | timer:stop() 108 | timer:close() 109 | -- `picker.manager` will be false when the picker is first created. We 110 | -- need to wait for it to be initialized before attempting to 111 | -- `select_some()`. 112 | if picker.manager ~= false then 113 | select_some(picker.prompt_bufnr, np_opts.preselected) 114 | end 115 | end) 116 | ) 117 | end 118 | 119 | local M = {} 120 | M.show_picker = show_picker 121 | return M 122 | -------------------------------------------------------------------------------- /src/report.ts: -------------------------------------------------------------------------------- 1 | import type * as P from '@playwright/test/reporter'; 2 | import type * as neotest from 'neotest'; 3 | import { cleanAnsi } from 'neotest-playwright.util'; 4 | import { options } from './adapter-options'; 5 | import { emitError } from './helpers'; 6 | 7 | // ### Output ### 8 | 9 | export const decodeOutput = (data: string): P.JSONReport => { 10 | const [ok, parsed] = pcall(vim.json.decode, data, { 11 | luanil: { object: true }, 12 | }); 13 | 14 | if (!ok) { 15 | emitError('Failed to parse test output json'); 16 | throw new Error('Failed to parse test output json'); 17 | } 18 | 19 | return parsed as P.JSONReport; 20 | }; 21 | 22 | export const parseOutput = (report: P.JSONReport): neotest.Results => { 23 | if (report.errors.length > 1) { 24 | emitError('Global errors found in report'); 25 | } 26 | 27 | const all_results = new Map(); 28 | 29 | // Multiple suites at root level may occur if (a) testDir is omitted from 30 | // config or (b) project "dependencies" have been set in config. 31 | 32 | for (const suite of report.suites) { 33 | const results = parseSuite(suite, report); 34 | for (const [key, result] of Object.entries(results)) { 35 | all_results.set(key, result); 36 | } 37 | } 38 | 39 | if (all_results.size === 0) { 40 | emitError('No test suites found in report'); 41 | } 42 | 43 | return Object.fromEntries(all_results); 44 | }; 45 | 46 | // ### Suite ### 47 | 48 | export const parseSuite = ( 49 | suite: P.JSONReportSuite, 50 | report: P.JSONReport, 51 | ): neotest.Results => { 52 | const results: neotest.Results = {}; 53 | 54 | const specs = flattenSpecs([suite]); 55 | 56 | // Parse specs 57 | for (const spec of specs) { 58 | let key: string; 59 | if (options.enable_dynamic_test_discovery) { 60 | key = spec.id; 61 | } else { 62 | key = constructSpecKey(report, spec); 63 | } 64 | 65 | results[key] = parseSpec(spec); 66 | } 67 | 68 | return results; 69 | }; 70 | 71 | // export const flattenSpecs = (suite: P.JSONReportSuite) => { 72 | // let specs = suite.specs.map((spec) => ({ ...spec, suiteTitle: suite.title })); 73 | // 74 | // for (const nestedSuite of suite.suites ?? []) { 75 | // specs = specs.concat(flattenSpecs(nestedSuite)); 76 | // } 77 | // 78 | // return specs; 79 | // }; 80 | 81 | export const flattenSpecs = ( 82 | suites: P.JSONReportSuite[], 83 | ): P.JSONReportSpec[] => { 84 | let specs: P.JSONReportSpec[] = []; 85 | 86 | for (const suite of suites) { 87 | const suiteSpecs = suite.specs.map((spec) => ({ 88 | ...spec, 89 | suiteTitle: suite.title, 90 | })); 91 | specs = specs.concat(suiteSpecs, flattenSpecs(suite.suites ?? [])); 92 | } 93 | 94 | return specs; 95 | }; 96 | 97 | // ### Spec ### 98 | 99 | export const parseSpec = ( 100 | spec: P.JSONReportSpec, 101 | ): Omit => { 102 | const status = getSpecStatus(spec); 103 | const errors = collectSpecErrors(spec).map((s) => toNeotestError(s)); 104 | const attachments = spec.tests[0]?.results[0]?.attachments ?? []; // TODO: handle multiple tests/results (test runs) 105 | 106 | const data = { 107 | status, 108 | short: `${spec.title}: ${status}`, 109 | errors, 110 | attachments, 111 | }; 112 | 113 | return data; 114 | }; 115 | 116 | const getSpecStatus = (spec: P.JSONReportSpec): neotest.Result['status'] => { 117 | if (!spec.ok) { 118 | return 'failed'; 119 | } else if (spec.tests[0]?.status === 'skipped') { 120 | return 'skipped'; 121 | } else { 122 | return 'passed'; 123 | } 124 | }; 125 | 126 | const constructSpecKey = ( 127 | report: P.JSONReport, 128 | spec: P.JSONReportSpec, 129 | ): neotest.ResultKey => { 130 | const dir = report.config.rootDir; 131 | const file = spec.file; 132 | const name = spec.title; 133 | 134 | const key = `${dir}/${file}::${name}`; 135 | 136 | return key; 137 | }; 138 | 139 | /** Collect all errors from a spec by traversing spec -> tests[] -> results[]. 140 | * Return a single flat array containing any errors. */ 141 | const collectSpecErrors = (spec: P.JSONReportSpec): P.JSONReportError[] => { 142 | const errors: P.JSONReportError[] = []; 143 | 144 | for (const test of spec.tests) { 145 | for (const result of test.results) { 146 | errors.push(...result.errors); 147 | } 148 | } 149 | 150 | return errors; 151 | }; 152 | 153 | /** Convert Playwright error to neotest error */ 154 | const toNeotestError = (error: P.JSONReportError): neotest.Error => { 155 | const line = error.location?.line; 156 | return { 157 | message: cleanAnsi(error.message), 158 | line: line ? line - 1 : 0, 159 | }; 160 | }; 161 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "flake-compat": { 4 | "flake": false, 5 | "locked": { 6 | "lastModified": 1696426674, 7 | "narHash": "sha256-kvjfFW7WAETZlt09AgDn1MrtKzP7t90Vf7vypd3OL1U=", 8 | "owner": "edolstra", 9 | "repo": "flake-compat", 10 | "rev": "0f9255e01c2351cc7d116c072cb317785dd33b33", 11 | "type": "github" 12 | }, 13 | "original": { 14 | "owner": "edolstra", 15 | "repo": "flake-compat", 16 | "type": "github" 17 | } 18 | }, 19 | "flake-utils": { 20 | "inputs": { 21 | "systems": "systems" 22 | }, 23 | "locked": { 24 | "lastModified": 1710146030, 25 | "narHash": "sha256-SZ5L6eA7HJ/nmkzGG7/ISclqe6oZdOZTNoesiInkXPQ=", 26 | "owner": "numtide", 27 | "repo": "flake-utils", 28 | "rev": "b1d9ab70662946ef0850d488da1c9019f3a9752a", 29 | "type": "github" 30 | }, 31 | "original": { 32 | "owner": "numtide", 33 | "repo": "flake-utils", 34 | "type": "github" 35 | } 36 | }, 37 | "gitignore": { 38 | "inputs": { 39 | "nixpkgs": [ 40 | "pre-commit-hooks", 41 | "nixpkgs" 42 | ] 43 | }, 44 | "locked": { 45 | "lastModified": 1709087332, 46 | "narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=", 47 | "owner": "hercules-ci", 48 | "repo": "gitignore.nix", 49 | "rev": "637db329424fd7e46cf4185293b9cc8c88c95394", 50 | "type": "github" 51 | }, 52 | "original": { 53 | "owner": "hercules-ci", 54 | "repo": "gitignore.nix", 55 | "type": "github" 56 | } 57 | }, 58 | "nixpkgs": { 59 | "locked": { 60 | "lastModified": 1717196966, 61 | "narHash": "sha256-yZKhxVIKd2lsbOqYd5iDoUIwsRZFqE87smE2Vzf6Ck0=", 62 | "owner": "NixOS", 63 | "repo": "nixpkgs", 64 | "rev": "57610d2f8f0937f39dbd72251e9614b1561942d8", 65 | "type": "github" 66 | }, 67 | "original": { 68 | "owner": "NixOS", 69 | "ref": "nixos-unstable", 70 | "repo": "nixpkgs", 71 | "type": "github" 72 | } 73 | }, 74 | "nixpkgs-stable": { 75 | "locked": { 76 | "lastModified": 1710695816, 77 | "narHash": "sha256-3Eh7fhEID17pv9ZxrPwCLfqXnYP006RKzSs0JptsN84=", 78 | "owner": "NixOS", 79 | "repo": "nixpkgs", 80 | "rev": "614b4613980a522ba49f0d194531beddbb7220d3", 81 | "type": "github" 82 | }, 83 | "original": { 84 | "owner": "NixOS", 85 | "ref": "nixos-23.11", 86 | "repo": "nixpkgs", 87 | "type": "github" 88 | } 89 | }, 90 | "nixpkgs_2": { 91 | "locked": { 92 | "lastModified": 1710765496, 93 | "narHash": "sha256-p7ryWEeQfMwTB6E0wIUd5V2cFTgq+DRRBz2hYGnJZyA=", 94 | "owner": "NixOS", 95 | "repo": "nixpkgs", 96 | "rev": "e367f7a1fb93137af22a3908f00b9a35e2d286a7", 97 | "type": "github" 98 | }, 99 | "original": { 100 | "owner": "NixOS", 101 | "ref": "nixpkgs-unstable", 102 | "repo": "nixpkgs", 103 | "type": "github" 104 | } 105 | }, 106 | "pre-commit-hooks": { 107 | "inputs": { 108 | "flake-compat": "flake-compat", 109 | "gitignore": "gitignore", 110 | "nixpkgs": "nixpkgs_2", 111 | "nixpkgs-stable": "nixpkgs-stable" 112 | }, 113 | "locked": { 114 | "lastModified": 1716213921, 115 | "narHash": "sha256-xrsYFST8ij4QWaV6HEokCUNIZLjjLP1bYC60K8XiBVA=", 116 | "owner": "cachix", 117 | "repo": "pre-commit-hooks.nix", 118 | "rev": "0e8fcc54b842ad8428c9e705cb5994eaf05c26a0", 119 | "type": "github" 120 | }, 121 | "original": { 122 | "owner": "cachix", 123 | "repo": "pre-commit-hooks.nix", 124 | "type": "github" 125 | } 126 | }, 127 | "root": { 128 | "inputs": { 129 | "flake-utils": "flake-utils", 130 | "nixpkgs": "nixpkgs", 131 | "pre-commit-hooks": "pre-commit-hooks" 132 | } 133 | }, 134 | "systems": { 135 | "locked": { 136 | "lastModified": 1681028828, 137 | "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", 138 | "owner": "nix-systems", 139 | "repo": "default", 140 | "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", 141 | "type": "github" 142 | }, 143 | "original": { 144 | "owner": "nix-systems", 145 | "repo": "default", 146 | "type": "github" 147 | } 148 | } 149 | }, 150 | "root": "root", 151 | "version": 7 152 | } 153 | -------------------------------------------------------------------------------- /src/discover.ts: -------------------------------------------------------------------------------- 1 | import type { BuildPosition, PositionId } from 'neotest'; 2 | import * as lib from 'neotest.lib'; 3 | import { data } from './adapter-data'; 4 | import { options } from './adapter-options'; 5 | import { logger } from './logging'; 6 | import { get_config } from './playwright'; 7 | import { buildTestPosition } from './position'; 8 | import { loadPreselectedProjects } from './project'; 9 | import { flattenSpecs } from './report'; 10 | import type { Adapter } from './types/adapter'; 11 | 12 | export const root: Adapter['root'] = lib.files.match_root_pattern( 13 | 'playwright.config.ts', 14 | 'playwright.config.js', 15 | ); 16 | 17 | export const filterDir: Adapter['filter_dir'] = ( 18 | name: string, 19 | _rel_path: string, 20 | _root: string, 21 | ) => { 22 | return name !== 'node_modules'; 23 | }; 24 | 25 | export const isTestFile: Adapter['is_test_file'] = ( 26 | file_path: string | undefined, 27 | ): boolean => { 28 | if (!file_path) { 29 | return false; 30 | } 31 | 32 | // TODO: Don't hardcode. Either get from user config or resolve using playwright cli. 33 | const endings = [ 34 | '.spec.ts', 35 | '.spec.tsx', 36 | '.test.ts', 37 | '.test.tsx', 38 | '.spec.js', 39 | '.spec.jsx', 40 | '.test.js', 41 | '.test.jsx', 42 | ]; 43 | 44 | const result = endings.some((ending) => file_path.endsWith(ending)); 45 | 46 | return result; 47 | }; 48 | 49 | export const discoverPositions: Adapter['discover_positions'] = ( 50 | path: string, 51 | ) => { 52 | // make sure data is populated in a subprocess 53 | // https://github.com/nvim-neotest/neotest/issues/210 54 | if (lib.subprocess.enabled()) { 55 | // This is async and will wait for the function to return 56 | lib.subprocess.call("require('neotest-playwright.discover').populate_data"); 57 | } else { 58 | populate_data(); 59 | } 60 | 61 | const query = /* query */ ` 62 | ; -- Namespaces -- 63 | 64 | ; Matches: test.describe('title') 65 | 66 | (call_expression 67 | function: (member_expression) @func_name (#eq? @func_name "test.describe") 68 | 69 | arguments: (arguments 70 | (string (string_fragment) @namespace.name) 71 | ) @namespace.definition 72 | ) 73 | 74 | ; -- Tests -- 75 | 76 | ; Matches: test('title') 77 | 78 | (call_expression 79 | function: (identifier) @func_name (#eq? @func_name "test") 80 | 81 | arguments: (arguments 82 | (string (string_fragment) @test.name 83 | ) 84 | ) @test.definition 85 | ) 86 | 87 | ; Matches: test.only('title') / test.fixme('title') 88 | 89 | (call_expression 90 | function: (member_expression) @func_name (#any-of? @func_name "test.only" "test.fixme" "test.skip") 91 | 92 | arguments: (arguments 93 | (string (string_fragment) @test.name) 94 | ) @test.definition 95 | ) 96 | `; 97 | 98 | return lib.treesitter.parse_positions(path, query, { 99 | nested_tests: true, 100 | position_id: 'require("neotest-playwright.discover")._position_id', 101 | ...(options.enable_dynamic_test_discovery 102 | ? { 103 | build_position: 104 | 'require("neotest-playwright.discover")._build_position', 105 | } 106 | : {}), 107 | }); 108 | }; 109 | 110 | const getMatchType = (node: NodeMatch) => { 111 | if ('test.name' in node) { 112 | return 'test'; 113 | } else if ('namespace.name' in node) { 114 | return 'namespace'; 115 | } else { 116 | throw new Error('Unknown match type'); 117 | } 118 | }; 119 | 120 | export const _build_position: BuildPosition = ( 121 | filePath, 122 | source, 123 | capturedNodes, 124 | ) => { 125 | const match_type = getMatchType(capturedNodes); 126 | 127 | const name = vim.treesitter.get_node_text( 128 | capturedNodes[`${match_type}.name`], 129 | source, 130 | ) as string; 131 | 132 | const definition = capturedNodes[`${match_type}.definition`]; 133 | // @ts-expect-error update type 134 | const range = [definition.range()] as unknown as Range; 135 | 136 | if (match_type === 'namespace') { 137 | return { 138 | type: match_type, 139 | range, 140 | path: filePath, 141 | name, 142 | }; 143 | // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition 144 | } else if (match_type === 'test') { 145 | const base = { 146 | type: match_type, 147 | range, 148 | path: filePath, 149 | name, 150 | } as const; 151 | 152 | const position = buildTestPosition(base); 153 | 154 | return position; 155 | } else { 156 | throw new Error('Unknown match type'); 157 | } 158 | }; 159 | 160 | export const _position_id: PositionId = (position, _parent) => { 161 | if (position.id) { 162 | return position.id; 163 | } else { 164 | return position.path + '::' + position.name; 165 | } 166 | }; 167 | 168 | export const populate_data = () => { 169 | if (shouldRefreshData()) { 170 | refresh_data(); 171 | } 172 | }; 173 | 174 | /** Called by the subprocess before parsing a file */ 175 | export const refresh_data = () => { 176 | logger('debug', 'Refreshing data'); 177 | const report = get_config(); 178 | 179 | data.report = report; // TODO: do we need to store this? 180 | data.specs = flattenSpecs(report.suites); 181 | data.rootDir = report.config.rootDir; 182 | options.projects = loadPreselectedProjects() ?? []; 183 | }; 184 | 185 | const shouldRefreshData = () => { 186 | if (data.specs && data.rootDir) { 187 | logger('debug', 'Data already exists. Skipping refresh.'); 188 | // data already exists 189 | return false; 190 | } else { 191 | return true; 192 | } 193 | }; 194 | -------------------------------------------------------------------------------- /src/util.lua: -------------------------------------------------------------------------------- 1 | local async = require('neotest.async') 2 | local vim = vim 3 | local validate = vim.validate 4 | local uv = vim.loop 5 | 6 | local M = {} 7 | 8 | -- Some path utilities 9 | M.path = (function() 10 | local is_windows = uv.os_uname().version:match('Windows') 11 | 12 | local function sanitize(path) 13 | if is_windows then 14 | path = path:sub(1, 1):upper() .. path:sub(2) 15 | path = path:gsub('\\', '/') 16 | end 17 | return path 18 | end 19 | 20 | local function exists(filename) 21 | local stat = uv.fs_stat(filename) 22 | return stat and stat.type or false 23 | end 24 | 25 | local function is_dir(filename) 26 | return exists(filename) == 'directory' 27 | end 28 | 29 | local function is_file(filename) 30 | return exists(filename) == 'file' 31 | end 32 | 33 | local function is_fs_root(path) 34 | if is_windows then 35 | return path:match('^%a:$') 36 | else 37 | return path == '/' 38 | end 39 | end 40 | 41 | local function is_absolute(filename) 42 | if is_windows then 43 | return filename:match('^%a:') or filename:match('^\\\\') 44 | else 45 | return filename:match('^/') 46 | end 47 | end 48 | 49 | local function dirname(path) 50 | local strip_dir_pat = '/([^/]+)$' 51 | local strip_sep_pat = '/$' 52 | if not path or #path == 0 then 53 | return 54 | end 55 | local result = path:gsub(strip_sep_pat, ''):gsub(strip_dir_pat, '') 56 | if #result == 0 then 57 | if is_windows then 58 | return path:sub(1, 2):upper() 59 | else 60 | return '/' 61 | end 62 | end 63 | return result 64 | end 65 | 66 | local function path_join(...) 67 | return table.concat(vim.tbl_flatten({ ... }), '/') 68 | end 69 | 70 | -- Traverse the path calling cb along the way. 71 | local function traverse_parents(path, cb) 72 | path = uv.fs_realpath(path) 73 | local dir = path 74 | -- Just in case our algo is buggy, don't infinite loop. 75 | for _ = 1, 100 do 76 | dir = dirname(dir) 77 | if not dir then 78 | return 79 | end 80 | -- If we can't ascend further, then stop looking. 81 | if cb(dir, path) then 82 | return dir, path 83 | end 84 | if is_fs_root(dir) then 85 | break 86 | end 87 | end 88 | end 89 | 90 | -- Iterate the path until we find the rootdir. 91 | local function iterate_parents(path) 92 | local function it(_, v) 93 | if v and not is_fs_root(v) then 94 | v = dirname(v) 95 | else 96 | return 97 | end 98 | if v and uv.fs_realpath(v) then 99 | return v, path 100 | else 101 | return 102 | end 103 | end 104 | return it, path, path 105 | end 106 | 107 | local function is_descendant(root, path) 108 | if not path then 109 | return false 110 | end 111 | 112 | local function cb(dir, _) 113 | return dir == root 114 | end 115 | 116 | local dir, _ = traverse_parents(path, cb) 117 | 118 | return dir == root 119 | end 120 | 121 | local path_separator = is_windows and ';' or ':' 122 | 123 | return { 124 | is_dir = is_dir, 125 | is_file = is_file, 126 | is_absolute = is_absolute, 127 | exists = exists, 128 | dirname = dirname, 129 | join = path_join, 130 | sanitize = sanitize, 131 | traverse_parents = traverse_parents, 132 | iterate_parents = iterate_parents, 133 | is_descendant = is_descendant, 134 | path_separator = path_separator, 135 | } 136 | end)() 137 | 138 | function M.search_ancestors(startpath, func) 139 | validate({ func = { func, 'f' } }) 140 | if func(startpath) then 141 | return startpath 142 | end 143 | local guard = 100 144 | for path in M.path.iterate_parents(startpath) do 145 | -- Prevent infinite recursion if our algorithm breaks 146 | guard = guard - 1 147 | if guard == 0 then 148 | return 149 | end 150 | 151 | if func(path) then 152 | return path 153 | end 154 | end 155 | end 156 | 157 | function M.root_pattern(...) 158 | local patterns = vim.tbl_flatten({ ... }) 159 | local function matcher(path) 160 | for _, pattern in ipairs(patterns) do 161 | for _, p in ipairs(vim.fn.glob(M.path.join(path, pattern), true, true)) do 162 | if M.path.exists(p) then 163 | return path 164 | end 165 | end 166 | end 167 | end 168 | return function(startpath) 169 | return M.search_ancestors(startpath, matcher) 170 | end 171 | end 172 | 173 | function M.find_node_modules_ancestor(startpath) 174 | return M.search_ancestors(startpath, function(path) 175 | if M.path.is_dir(M.path.join(path, 'node_modules')) then 176 | return path 177 | end 178 | end) 179 | end 180 | function M.find_package_json_ancestor(startpath) 181 | return M.search_ancestors(startpath, function(path) 182 | if M.path.is_file(M.path.join(path, 'package.json')) then 183 | return path 184 | end 185 | end) 186 | end 187 | 188 | -- Find the closest ancestor containing a file or directory with the given name. 189 | 190 | ---@param startpath string The path to start searching from. 191 | ---@param name string The name of the file or directory to search for. 192 | ---@param is_dir boolean Whether to search for a directory or a file. 193 | ---@return string | nil 194 | function M.find_ancestor(startpath, name, is_dir) 195 | return M.search_ancestors(startpath, function(path) 196 | local exists = is_dir and M.path.is_dir or M.path.is_file 197 | if exists(M.path.join(path, name)) then 198 | return path 199 | end 200 | end) 201 | end 202 | 203 | function M.cleanAnsi(s) 204 | return s:gsub('\x1b%[%d+;%d+;%d+;%d+;%d+m', '') 205 | :gsub('\x1b%[%d+;%d+;%d+;%d+m', '') 206 | :gsub('\x1b%[%d+;%d+;%d+m', '') 207 | :gsub('\x1b%[%d+;%d+m', '') 208 | :gsub('\x1b%[%d+m', '') 209 | end 210 | 211 | return M 212 | -------------------------------------------------------------------------------- /lua/neotest-playwright/util.lua: -------------------------------------------------------------------------------- 1 | local async = require('neotest.async') 2 | local vim = vim 3 | local validate = vim.validate 4 | local uv = vim.loop 5 | 6 | local M = {} 7 | 8 | -- Some path utilities 9 | M.path = (function() 10 | local is_windows = uv.os_uname().version:match('Windows') 11 | 12 | local function sanitize(path) 13 | if is_windows then 14 | path = path:sub(1, 1):upper() .. path:sub(2) 15 | path = path:gsub('\\', '/') 16 | end 17 | return path 18 | end 19 | 20 | local function exists(filename) 21 | local stat = uv.fs_stat(filename) 22 | return stat and stat.type or false 23 | end 24 | 25 | local function is_dir(filename) 26 | return exists(filename) == 'directory' 27 | end 28 | 29 | local function is_file(filename) 30 | return exists(filename) == 'file' 31 | end 32 | 33 | local function is_fs_root(path) 34 | if is_windows then 35 | return path:match('^%a:$') 36 | else 37 | return path == '/' 38 | end 39 | end 40 | 41 | local function is_absolute(filename) 42 | if is_windows then 43 | return filename:match('^%a:') or filename:match('^\\\\') 44 | else 45 | return filename:match('^/') 46 | end 47 | end 48 | 49 | local function dirname(path) 50 | local strip_dir_pat = '/([^/]+)$' 51 | local strip_sep_pat = '/$' 52 | if not path or #path == 0 then 53 | return 54 | end 55 | local result = path:gsub(strip_sep_pat, ''):gsub(strip_dir_pat, '') 56 | if #result == 0 then 57 | if is_windows then 58 | return path:sub(1, 2):upper() 59 | else 60 | return '/' 61 | end 62 | end 63 | return result 64 | end 65 | 66 | local function path_join(...) 67 | return table.concat(vim.tbl_flatten({ ... }), '/') 68 | end 69 | 70 | -- Traverse the path calling cb along the way. 71 | local function traverse_parents(path, cb) 72 | path = uv.fs_realpath(path) 73 | local dir = path 74 | -- Just in case our algo is buggy, don't infinite loop. 75 | for _ = 1, 100 do 76 | dir = dirname(dir) 77 | if not dir then 78 | return 79 | end 80 | -- If we can't ascend further, then stop looking. 81 | if cb(dir, path) then 82 | return dir, path 83 | end 84 | if is_fs_root(dir) then 85 | break 86 | end 87 | end 88 | end 89 | 90 | -- Iterate the path until we find the rootdir. 91 | local function iterate_parents(path) 92 | local function it(_, v) 93 | if v and not is_fs_root(v) then 94 | v = dirname(v) 95 | else 96 | return 97 | end 98 | if v and uv.fs_realpath(v) then 99 | return v, path 100 | else 101 | return 102 | end 103 | end 104 | return it, path, path 105 | end 106 | 107 | local function is_descendant(root, path) 108 | if not path then 109 | return false 110 | end 111 | 112 | local function cb(dir, _) 113 | return dir == root 114 | end 115 | 116 | local dir, _ = traverse_parents(path, cb) 117 | 118 | return dir == root 119 | end 120 | 121 | local path_separator = is_windows and ';' or ':' 122 | 123 | return { 124 | is_dir = is_dir, 125 | is_file = is_file, 126 | is_absolute = is_absolute, 127 | exists = exists, 128 | dirname = dirname, 129 | join = path_join, 130 | sanitize = sanitize, 131 | traverse_parents = traverse_parents, 132 | iterate_parents = iterate_parents, 133 | is_descendant = is_descendant, 134 | path_separator = path_separator, 135 | } 136 | end)() 137 | 138 | function M.search_ancestors(startpath, func) 139 | validate({ func = { func, 'f' } }) 140 | if func(startpath) then 141 | return startpath 142 | end 143 | local guard = 100 144 | for path in M.path.iterate_parents(startpath) do 145 | -- Prevent infinite recursion if our algorithm breaks 146 | guard = guard - 1 147 | if guard == 0 then 148 | return 149 | end 150 | 151 | if func(path) then 152 | return path 153 | end 154 | end 155 | end 156 | 157 | function M.root_pattern(...) 158 | local patterns = vim.tbl_flatten({ ... }) 159 | local function matcher(path) 160 | for _, pattern in ipairs(patterns) do 161 | for _, p in ipairs(vim.fn.glob(M.path.join(path, pattern), true, true)) do 162 | if M.path.exists(p) then 163 | return path 164 | end 165 | end 166 | end 167 | end 168 | return function(startpath) 169 | return M.search_ancestors(startpath, matcher) 170 | end 171 | end 172 | 173 | function M.find_node_modules_ancestor(startpath) 174 | return M.search_ancestors(startpath, function(path) 175 | if M.path.is_dir(M.path.join(path, 'node_modules')) then 176 | return path 177 | end 178 | end) 179 | end 180 | function M.find_package_json_ancestor(startpath) 181 | return M.search_ancestors(startpath, function(path) 182 | if M.path.is_file(M.path.join(path, 'package.json')) then 183 | return path 184 | end 185 | end) 186 | end 187 | 188 | -- Find the closest ancestor containing a file or directory with the given name. 189 | 190 | ---@param startpath string The path to start searching from. 191 | ---@param name string The name of the file or directory to search for. 192 | ---@param is_dir boolean Whether to search for a directory or a file. 193 | ---@return string | nil 194 | function M.find_ancestor(startpath, name, is_dir) 195 | return M.search_ancestors(startpath, function(path) 196 | local exists = is_dir and M.path.is_dir or M.path.is_file 197 | if exists(M.path.join(path, name)) then 198 | return path 199 | end 200 | end) 201 | end 202 | 203 | function M.cleanAnsi(s) 204 | return s:gsub('\x1b%[%d+;%d+;%d+;%d+;%d+m', '') 205 | :gsub('\x1b%[%d+;%d+;%d+;%d+m', '') 206 | :gsub('\x1b%[%d+;%d+;%d+m', '') 207 | :gsub('\x1b%[%d+;%d+m', '') 208 | :gsub('\x1b%[%d+m', '') 209 | end 210 | 211 | return M 212 | -------------------------------------------------------------------------------- /lua/neotest-playwright/report-io.lua: -------------------------------------------------------------------------------- 1 | --[[ Generated with https://github.com/TypeScriptToLua/TypeScriptToLua ]] 2 | -- Lua Library inline imports 3 | local function __TS__StringIncludes(self, searchString, position) 4 | if not position then 5 | position = 1 6 | else 7 | position = position + 1 8 | end 9 | local index = string.find(self, searchString, position, true) 10 | return index ~= nil 11 | end 12 | 13 | local function __TS__New(target, ...) 14 | local instance = setmetatable({}, target.prototype) 15 | instance:____constructor(...) 16 | return instance 17 | end 18 | 19 | local function __TS__Class(self) 20 | local c = {prototype = {}} 21 | c.prototype.__index = c.prototype 22 | c.prototype.constructor = c 23 | return c 24 | end 25 | 26 | local function __TS__ClassExtends(target, base) 27 | target.____super = base 28 | local staticMetatable = setmetatable({__index = base}, base) 29 | setmetatable(target, staticMetatable) 30 | local baseMetatable = getmetatable(base) 31 | if baseMetatable then 32 | if type(baseMetatable.__index) == "function" then 33 | staticMetatable.__index = baseMetatable.__index 34 | end 35 | if type(baseMetatable.__newindex) == "function" then 36 | staticMetatable.__newindex = baseMetatable.__newindex 37 | end 38 | end 39 | setmetatable(target.prototype, base.prototype) 40 | if type(base.prototype.__index) == "function" then 41 | target.prototype.__index = base.prototype.__index 42 | end 43 | if type(base.prototype.__newindex) == "function" then 44 | target.prototype.__newindex = base.prototype.__newindex 45 | end 46 | if type(base.prototype.__tostring) == "function" then 47 | target.prototype.__tostring = base.prototype.__tostring 48 | end 49 | end 50 | 51 | local Error, RangeError, ReferenceError, SyntaxError, TypeError, URIError 52 | do 53 | local function getErrorStack(self, constructor) 54 | local level = 1 55 | while true do 56 | local info = debug.getinfo(level, "f") 57 | level = level + 1 58 | if not info then 59 | level = 1 60 | break 61 | elseif info.func == constructor then 62 | break 63 | end 64 | end 65 | if __TS__StringIncludes(_VERSION, "Lua 5.0") then 66 | return debug.traceback(("[Level " .. tostring(level)) .. "]") 67 | else 68 | return debug.traceback(nil, level) 69 | end 70 | end 71 | local function wrapErrorToString(self, getDescription) 72 | return function(self) 73 | local description = getDescription(self) 74 | local caller = debug.getinfo(3, "f") 75 | local isClassicLua = __TS__StringIncludes(_VERSION, "Lua 5.0") or _VERSION == "Lua 5.1" 76 | if isClassicLua or caller and caller.func ~= error then 77 | return description 78 | else 79 | return (description .. "\n") .. tostring(self.stack) 80 | end 81 | end 82 | end 83 | local function initErrorClass(self, Type, name) 84 | Type.name = name 85 | return setmetatable( 86 | Type, 87 | {__call = function(____, _self, message) return __TS__New(Type, message) end} 88 | ) 89 | end 90 | local ____initErrorClass_1 = initErrorClass 91 | local ____class_0 = __TS__Class() 92 | ____class_0.name = "" 93 | function ____class_0.prototype.____constructor(self, message) 94 | if message == nil then 95 | message = "" 96 | end 97 | self.message = message 98 | self.name = "Error" 99 | self.stack = getErrorStack(nil, self.constructor.new) 100 | local metatable = getmetatable(self) 101 | if metatable and not metatable.__errorToStringPatched then 102 | metatable.__errorToStringPatched = true 103 | metatable.__tostring = wrapErrorToString(nil, metatable.__tostring) 104 | end 105 | end 106 | function ____class_0.prototype.__tostring(self) 107 | return self.message ~= "" and (self.name .. ": ") .. self.message or self.name 108 | end 109 | Error = ____initErrorClass_1(nil, ____class_0, "Error") 110 | local function createErrorClass(self, name) 111 | local ____initErrorClass_3 = initErrorClass 112 | local ____class_2 = __TS__Class() 113 | ____class_2.name = ____class_2.name 114 | __TS__ClassExtends(____class_2, Error) 115 | function ____class_2.prototype.____constructor(self, ...) 116 | ____class_2.____super.prototype.____constructor(self, ...) 117 | self.name = name 118 | end 119 | return ____initErrorClass_3(nil, ____class_2, name) 120 | end 121 | RangeError = createErrorClass(nil, "RangeError") 122 | ReferenceError = createErrorClass(nil, "ReferenceError") 123 | SyntaxError = createErrorClass(nil, "SyntaxError") 124 | TypeError = createErrorClass(nil, "TypeError") 125 | URIError = createErrorClass(nil, "URIError") 126 | end 127 | -- End of Lua Library inline imports 128 | local ____exports = {} 129 | local lib = require("neotest.lib") 130 | ____exports.readReport = function(file) 131 | local success, data = pcall(lib.files.read, file) 132 | if not success then 133 | error( 134 | __TS__New(Error, "Failed to read test output file: " .. file), 135 | 0 136 | ) 137 | end 138 | local ok, parsed = pcall(vim.json.decode, data, {luanil = {object = true}}) 139 | if not ok then 140 | error( 141 | __TS__New(Error, "Failed to parse test output json: " .. file), 142 | 0 143 | ) 144 | end 145 | return parsed 146 | end 147 | return ____exports 148 | -------------------------------------------------------------------------------- /lua/neotest-playwright/finders.lua: -------------------------------------------------------------------------------- 1 | --[[ Generated with https://github.com/TypeScriptToLua/TypeScriptToLua ]] 2 | -- Lua Library inline imports 3 | local function __TS__StringIncludes(self, searchString, position) 4 | if not position then 5 | position = 1 6 | else 7 | position = position + 1 8 | end 9 | local index = string.find(self, searchString, position, true) 10 | return index ~= nil 11 | end 12 | 13 | local function __TS__New(target, ...) 14 | local instance = setmetatable({}, target.prototype) 15 | instance:____constructor(...) 16 | return instance 17 | end 18 | 19 | local function __TS__Class(self) 20 | local c = {prototype = {}} 21 | c.prototype.__index = c.prototype 22 | c.prototype.constructor = c 23 | return c 24 | end 25 | 26 | local function __TS__ClassExtends(target, base) 27 | target.____super = base 28 | local staticMetatable = setmetatable({__index = base}, base) 29 | setmetatable(target, staticMetatable) 30 | local baseMetatable = getmetatable(base) 31 | if baseMetatable then 32 | if type(baseMetatable.__index) == "function" then 33 | staticMetatable.__index = baseMetatable.__index 34 | end 35 | if type(baseMetatable.__newindex) == "function" then 36 | staticMetatable.__newindex = baseMetatable.__newindex 37 | end 38 | end 39 | setmetatable(target.prototype, base.prototype) 40 | if type(base.prototype.__index) == "function" then 41 | target.prototype.__index = base.prototype.__index 42 | end 43 | if type(base.prototype.__newindex) == "function" then 44 | target.prototype.__newindex = base.prototype.__newindex 45 | end 46 | if type(base.prototype.__tostring) == "function" then 47 | target.prototype.__tostring = base.prototype.__tostring 48 | end 49 | end 50 | 51 | local Error, RangeError, ReferenceError, SyntaxError, TypeError, URIError 52 | do 53 | local function getErrorStack(self, constructor) 54 | local level = 1 55 | while true do 56 | local info = debug.getinfo(level, "f") 57 | level = level + 1 58 | if not info then 59 | level = 1 60 | break 61 | elseif info.func == constructor then 62 | break 63 | end 64 | end 65 | if __TS__StringIncludes(_VERSION, "Lua 5.0") then 66 | return debug.traceback(("[Level " .. tostring(level)) .. "]") 67 | else 68 | return debug.traceback(nil, level) 69 | end 70 | end 71 | local function wrapErrorToString(self, getDescription) 72 | return function(self) 73 | local description = getDescription(self) 74 | local caller = debug.getinfo(3, "f") 75 | local isClassicLua = __TS__StringIncludes(_VERSION, "Lua 5.0") or _VERSION == "Lua 5.1" 76 | if isClassicLua or caller and caller.func ~= error then 77 | return description 78 | else 79 | return (description .. "\n") .. tostring(self.stack) 80 | end 81 | end 82 | end 83 | local function initErrorClass(self, Type, name) 84 | Type.name = name 85 | return setmetatable( 86 | Type, 87 | {__call = function(____, _self, message) return __TS__New(Type, message) end} 88 | ) 89 | end 90 | local ____initErrorClass_1 = initErrorClass 91 | local ____class_0 = __TS__Class() 92 | ____class_0.name = "" 93 | function ____class_0.prototype.____constructor(self, message) 94 | if message == nil then 95 | message = "" 96 | end 97 | self.message = message 98 | self.name = "Error" 99 | self.stack = getErrorStack(nil, self.constructor.new) 100 | local metatable = getmetatable(self) 101 | if metatable and not metatable.__errorToStringPatched then 102 | metatable.__errorToStringPatched = true 103 | metatable.__tostring = wrapErrorToString(nil, metatable.__tostring) 104 | end 105 | end 106 | function ____class_0.prototype.__tostring(self) 107 | return self.message ~= "" and (self.name .. ": ") .. self.message or self.name 108 | end 109 | Error = ____initErrorClass_1(nil, ____class_0, "Error") 110 | local function createErrorClass(self, name) 111 | local ____initErrorClass_3 = initErrorClass 112 | local ____class_2 = __TS__Class() 113 | ____class_2.name = ____class_2.name 114 | __TS__ClassExtends(____class_2, Error) 115 | function ____class_2.prototype.____constructor(self, ...) 116 | ____class_2.____super.prototype.____constructor(self, ...) 117 | self.name = name 118 | end 119 | return ____initErrorClass_3(nil, ____class_2, name) 120 | end 121 | RangeError = createErrorClass(nil, "RangeError") 122 | ReferenceError = createErrorClass(nil, "ReferenceError") 123 | SyntaxError = createErrorClass(nil, "SyntaxError") 124 | TypeError = createErrorClass(nil, "TypeError") 125 | URIError = createErrorClass(nil, "URIError") 126 | end 127 | -- End of Lua Library inline imports 128 | local ____exports = {} 129 | local lib = require("neotest.lib") 130 | local ____logging = require('neotest-playwright.logging') 131 | local logger = ____logging.logger 132 | ____exports.getPlaywrightBinary = function() 133 | local dir = ____exports.get_cwd() 134 | local node_modules = dir .. "/node_modules" 135 | local bin = node_modules .. "/.bin/playwright" 136 | if lib.files.exists(bin) then 137 | return bin 138 | else 139 | logger("error", "playwright binary does not exist at ", bin) 140 | error( 141 | __TS__New(Error, "Unable to locate playwright binary. Expected to find it at: " .. bin), 142 | 0 143 | ) 144 | end 145 | end 146 | ____exports.getPlaywrightConfig = function() 147 | local dir = ____exports.get_cwd() 148 | local configs = {dir .. "/playwright.config.ts", dir .. "/playwright.config.js"} 149 | for ____, config in ipairs(configs) do 150 | if lib.files.exists(config) then 151 | return config 152 | end 153 | end 154 | logger("error", "Unable to locate playwright config file.") 155 | error( 156 | __TS__New( 157 | Error, 158 | "Unable to locate playwright config file. Expected to find it at: " .. table.concat(configs, ", ") 159 | ), 160 | 0 161 | ) 162 | end 163 | ____exports.get_cwd = function() 164 | local dir = vim.loop.cwd() 165 | return dir 166 | end 167 | return ____exports 168 | -------------------------------------------------------------------------------- /tests/init_spec.lua: -------------------------------------------------------------------------------- 1 | local async = require('plenary.async.tests') 2 | local plugin = require('neotest-playwright').adapter 3 | local Tree = require('neotest.types').Tree 4 | 5 | require('neotest-playwright-assertions') 6 | 7 | A = function(...) 8 | print(vim.inspect(...)) 9 | end 10 | 11 | local test_file = './tests/sample/example.spec.ts' 12 | local config_file = './tests/sample/playwright.config.ts' 13 | 14 | describe('is_test_file', function() 15 | it('matches test files', function() 16 | assert.True(plugin.is_test_file(test_file)) 17 | end) 18 | 19 | it('does not match plain js files', function() 20 | assert.False(plugin.is_test_file('./index.js')) 21 | end) 22 | end) 23 | 24 | describe('discover_positions', function() 25 | async.it('provides meaningful names from a basic spec', function() 26 | local positions = plugin.discover_positions(test_file):to_list() 27 | 28 | local expected_output = { 29 | { 30 | name = 'example.spec.ts', 31 | type = 'file', 32 | }, 33 | { 34 | { 35 | name = 'math', 36 | type = 'namespace', 37 | }, 38 | { 39 | { 40 | name = 'addition', 41 | type = 'test', 42 | }, 43 | { 44 | name = 'not substraction', 45 | type = 'test', 46 | }, 47 | }, 48 | { 49 | name = 'common sense', 50 | type = 'test', 51 | }, 52 | { 53 | name = 'not so common sense', 54 | type = 'test', 55 | }, 56 | }, 57 | } 58 | 59 | assert.equals(expected_output[1].name, positions[1].name) 60 | assert.equals(expected_output[1].type, positions[1].type) 61 | assert.equals(expected_output[2][1].name, positions[2][1].name) 62 | assert.equals(expected_output[2][1].type, positions[2][1].type) 63 | 64 | -- assert.equals(5, #positions[2]) 65 | for i, value in ipairs(expected_output[2][2]) do 66 | assert.is.truthy(value) 67 | local position = positions[2][i + 1][1] 68 | assert.is.truthy(position) 69 | assert.equals(value.name, position.name) 70 | assert.equals(value.type, position.type) 71 | end 72 | end) 73 | 74 | -- async.it("provides meaningful names for array driven tests", function() 75 | -- local positions = plugin.discover_positions("./spec/array.test.ts"):to_list() 76 | -- 77 | -- local expected_output = { 78 | -- { 79 | -- name = "array.test.ts", 80 | -- type = "file", 81 | -- }, 82 | -- { 83 | -- { 84 | -- name = "describe text", 85 | -- type = "namespace", 86 | -- }, 87 | -- { 88 | -- { 89 | -- name = "Array1", 90 | -- type = "test", 91 | -- }, 92 | -- { 93 | -- name = "Array2", 94 | -- type = "test", 95 | -- }, 96 | -- { 97 | -- name = "Array3", 98 | -- type = "test", 99 | -- }, 100 | -- { 101 | -- name = "Array4", 102 | -- type = "test", 103 | -- }, 104 | -- }, 105 | -- }, 106 | -- } 107 | -- 108 | -- assert.equals(expected_output[1].name, positions[1].name) 109 | -- assert.equals(expected_output[1].type, positions[1].type) 110 | -- assert.equals(expected_output[2][1].name, positions[2][1].name) 111 | -- assert.equals(expected_output[2][1].type, positions[2][1].type) 112 | -- assert.equals(5, #positions[2]) 113 | -- for i, value in ipairs(expected_output[2][2]) do 114 | -- assert.is.truthy(value) 115 | -- local position = positions[2][i + 1][1] 116 | -- assert.is.truthy(position) 117 | -- assert.equals(value.name, position.name) 118 | -- assert.equals(value.type, position.type) 119 | -- end 120 | -- end) 121 | end) 122 | 123 | describe('build_spec', function() 124 | async.it('builds command for file test', function() 125 | local positions = plugin.discover_positions(test_file):to_list() 126 | local tree = Tree.from_list(positions, function(pos) 127 | return pos.id 128 | end) 129 | local spec = plugin.build_spec({ tree = tree }) 130 | 131 | assert.is.truthy(spec) 132 | local command = spec.command 133 | assert.is.truthy(command) 134 | assert.contains(command, './node_modules/.bin/playwright') 135 | assert.contains(command, 'test') 136 | assert.contains(command, '--reporter=list,json') 137 | assert.contains(command, '--config=' .. config_file) 138 | -- assert.is_not.contains(command, "--config=jest.config.js") 139 | assert.contains(command, test_file) 140 | assert.is.truthy(spec.context.file) 141 | assert.is.truthy(spec.context.results_path) 142 | assert.is.truthy(spec.env.PLAYWRIGHT_JSON_OUTPUT_NAME) 143 | end) 144 | 145 | async.it('builds command for namespace', function() 146 | local positions = plugin.discover_positions(test_file):to_list() 147 | 148 | local tree = Tree.from_list(positions, function(pos) 149 | return pos.id 150 | end) 151 | 152 | local spec = plugin.build_spec({ tree = tree:children()[1] }) 153 | 154 | assert.is.truthy(spec) 155 | local command = spec.command 156 | assert.is.truthy(command) 157 | assert.contains(command, './node_modules/.bin/playwright') 158 | assert.contains(command, 'test') 159 | assert.contains(command, '--reporter=list,json') 160 | assert.contains(command, '--config=' .. config_file) 161 | assert.contains(command, test_file .. ':3') 162 | assert.is.truthy(spec.context.file) 163 | assert.is.truthy(spec.context.results_path) 164 | assert.is.truthy(spec.env.PLAYWRIGHT_JSON_OUTPUT_NAME) 165 | end) 166 | 167 | async.it('builds command for nested namespace', function() 168 | local positions = plugin.discover_positions(test_file):to_list() 169 | 170 | local tree = Tree.from_list(positions, function(pos) 171 | return pos.id 172 | end) 173 | 174 | local spec = plugin.build_spec({ tree = tree:children()[1]:children()[1] }) 175 | print(vim.inspect(spec.command)) 176 | 177 | assert.is.truthy(spec) 178 | local command = spec.command 179 | assert.is.truthy(command) 180 | assert.contains(command, './node_modules/.bin/playwright') 181 | assert.contains(command, 'test') 182 | assert.contains(command, '--reporter=list,json') 183 | assert.contains(command, '--config=' .. config_file) 184 | assert.contains(command, test_file .. ':4') 185 | assert.is.truthy(spec.context.file) 186 | assert.is.truthy(spec.context.results_path) 187 | assert.is.truthy(spec.env.PLAYWRIGHT_JSON_OUTPUT_NAME) 188 | end) 189 | 190 | -- async.it("builds correct command for test name with ' ", function() 191 | -- local positions = plugin.discover_positions("./spec/nestedDescribe.test.ts"):to_list() 192 | -- 193 | -- local tree = Tree.from_list(positions, function(pos) 194 | -- return pos.id 195 | -- end) 196 | -- 197 | -- local spec = plugin.build_spec({ tree = tree:children()[1]:children()[1]:children()[2] }) 198 | -- assert.is.truthy(spec) 199 | -- local command = spec.command 200 | -- assert.is.truthy(command) 201 | -- assert.contains(command, "jest") 202 | -- assert.contains(command, "--json") 203 | -- assert.is_not.contains(command, "--config=jest.config.js") 204 | -- assert.contains(command, "--testNamePattern='^outer inner this has a \\'$'") 205 | -- assert.contains(command, "./spec/nestedDescribe.test.ts") 206 | -- assert.is.truthy(spec.context.file) 207 | -- assert.is.truthy(spec.context.results_path) 208 | -- end) 209 | end) 210 | -------------------------------------------------------------------------------- /lua/neotest-playwright/playwright.lua: -------------------------------------------------------------------------------- 1 | --[[ Generated with https://github.com/TypeScriptToLua/TypeScriptToLua ]] 2 | -- Lua Library inline imports 3 | local function __TS__ObjectEntries(obj) 4 | local result = {} 5 | local len = 0 6 | for key in pairs(obj) do 7 | len = len + 1 8 | result[len] = {key, obj[key]} 9 | end 10 | return result 11 | end 12 | 13 | local function __TS__ArrayMap(self, callbackfn, thisArg) 14 | local result = {} 15 | for i = 1, #self do 16 | result[i] = callbackfn(thisArg, self[i], i - 1, self) 17 | end 18 | return result 19 | end 20 | 21 | local function __TS__ArrayUnshift(self, ...) 22 | local items = {...} 23 | local numItemsToInsert = #items 24 | if numItemsToInsert == 0 then 25 | return #self 26 | end 27 | for i = #self, 1, -1 do 28 | self[i + numItemsToInsert] = self[i] 29 | end 30 | for i = 1, numItemsToInsert do 31 | self[i] = items[i] 32 | end 33 | return #self 34 | end 35 | 36 | local function __TS__StringIncludes(self, searchString, position) 37 | if not position then 38 | position = 1 39 | else 40 | position = position + 1 41 | end 42 | local index = string.find(self, searchString, position, true) 43 | return index ~= nil 44 | end 45 | 46 | local function __TS__New(target, ...) 47 | local instance = setmetatable({}, target.prototype) 48 | instance:____constructor(...) 49 | return instance 50 | end 51 | 52 | local function __TS__Class(self) 53 | local c = {prototype = {}} 54 | c.prototype.__index = c.prototype 55 | c.prototype.constructor = c 56 | return c 57 | end 58 | 59 | local function __TS__ClassExtends(target, base) 60 | target.____super = base 61 | local staticMetatable = setmetatable({__index = base}, base) 62 | setmetatable(target, staticMetatable) 63 | local baseMetatable = getmetatable(base) 64 | if baseMetatable then 65 | if type(baseMetatable.__index) == "function" then 66 | staticMetatable.__index = baseMetatable.__index 67 | end 68 | if type(baseMetatable.__newindex) == "function" then 69 | staticMetatable.__newindex = baseMetatable.__newindex 70 | end 71 | end 72 | setmetatable(target.prototype, base.prototype) 73 | if type(base.prototype.__index) == "function" then 74 | target.prototype.__index = base.prototype.__index 75 | end 76 | if type(base.prototype.__newindex) == "function" then 77 | target.prototype.__newindex = base.prototype.__newindex 78 | end 79 | if type(base.prototype.__tostring) == "function" then 80 | target.prototype.__tostring = base.prototype.__tostring 81 | end 82 | end 83 | 84 | local Error, RangeError, ReferenceError, SyntaxError, TypeError, URIError 85 | do 86 | local function getErrorStack(self, constructor) 87 | local level = 1 88 | while true do 89 | local info = debug.getinfo(level, "f") 90 | level = level + 1 91 | if not info then 92 | level = 1 93 | break 94 | elseif info.func == constructor then 95 | break 96 | end 97 | end 98 | if __TS__StringIncludes(_VERSION, "Lua 5.0") then 99 | return debug.traceback(("[Level " .. tostring(level)) .. "]") 100 | else 101 | return debug.traceback(nil, level) 102 | end 103 | end 104 | local function wrapErrorToString(self, getDescription) 105 | return function(self) 106 | local description = getDescription(self) 107 | local caller = debug.getinfo(3, "f") 108 | local isClassicLua = __TS__StringIncludes(_VERSION, "Lua 5.0") or _VERSION == "Lua 5.1" 109 | if isClassicLua or caller and caller.func ~= error then 110 | return description 111 | else 112 | return (description .. "\n") .. tostring(self.stack) 113 | end 114 | end 115 | end 116 | local function initErrorClass(self, Type, name) 117 | Type.name = name 118 | return setmetatable( 119 | Type, 120 | {__call = function(____, _self, message) return __TS__New(Type, message) end} 121 | ) 122 | end 123 | local ____initErrorClass_1 = initErrorClass 124 | local ____class_0 = __TS__Class() 125 | ____class_0.name = "" 126 | function ____class_0.prototype.____constructor(self, message) 127 | if message == nil then 128 | message = "" 129 | end 130 | self.message = message 131 | self.name = "Error" 132 | self.stack = getErrorStack(nil, self.constructor.new) 133 | local metatable = getmetatable(self) 134 | if metatable and not metatable.__errorToStringPatched then 135 | metatable.__errorToStringPatched = true 136 | metatable.__tostring = wrapErrorToString(nil, metatable.__tostring) 137 | end 138 | end 139 | function ____class_0.prototype.__tostring(self) 140 | return self.message ~= "" and (self.name .. ": ") .. self.message or self.name 141 | end 142 | Error = ____initErrorClass_1(nil, ____class_0, "Error") 143 | local function createErrorClass(self, name) 144 | local ____initErrorClass_3 = initErrorClass 145 | local ____class_2 = __TS__Class() 146 | ____class_2.name = ____class_2.name 147 | __TS__ClassExtends(____class_2, Error) 148 | function ____class_2.prototype.____constructor(self, ...) 149 | ____class_2.____super.prototype.____constructor(self, ...) 150 | self.name = name 151 | end 152 | return ____initErrorClass_3(nil, ____class_2, name) 153 | end 154 | RangeError = createErrorClass(nil, "RangeError") 155 | ReferenceError = createErrorClass(nil, "ReferenceError") 156 | SyntaxError = createErrorClass(nil, "SyntaxError") 157 | TypeError = createErrorClass(nil, "TypeError") 158 | URIError = createErrorClass(nil, "URIError") 159 | end 160 | -- End of Lua Library inline imports 161 | local ____exports = {} 162 | local run 163 | local ____adapter_2Doptions = require('neotest-playwright.adapter-options') 164 | local options = ____adapter_2Doptions.options 165 | local ____build_2Dcommand = require('neotest-playwright.build-command') 166 | local buildCommand = ____build_2Dcommand.buildCommand 167 | local ____helpers = require('neotest-playwright.helpers') 168 | local emitError = ____helpers.emitError 169 | local ____logging = require('neotest-playwright.logging') 170 | local logger = ____logging.logger 171 | ____exports.get_config = function() 172 | local cmd = buildCommand( 173 | { 174 | bin = options.get_playwright_binary(), 175 | config = options.get_playwright_config(), 176 | reporters = {"json"} 177 | }, 178 | {"--list"} 179 | ) 180 | __TS__ArrayUnshift( 181 | cmd, 182 | table.concat( 183 | __TS__ArrayMap( 184 | __TS__ObjectEntries(options.env), 185 | function(____, ____bindingPattern0) 186 | local value 187 | local key 188 | key = ____bindingPattern0[1] 189 | value = ____bindingPattern0[2] 190 | return (key .. "=") .. value 191 | end 192 | ), 193 | " " 194 | ) 195 | ) 196 | local output = run(table.concat(cmd, " ")) 197 | if not output then 198 | error( 199 | __TS__New(Error, "Failed to get Playwright config"), 200 | 0 201 | ) 202 | end 203 | return output 204 | end 205 | --- Returns the playwright config 206 | run = function(cmd) 207 | local handle, errmsg = io.popen(cmd) 208 | if type(errmsg) == "string" then 209 | logger("error", errmsg) 210 | end 211 | if not handle then 212 | emitError("Failed to execute command: " .. cmd) 213 | return 214 | end 215 | local output = handle:read("*a") 216 | handle:close() 217 | if type(output) ~= "string" then 218 | emitError("Failed to read output from command: " .. cmd) 219 | return 220 | end 221 | if output == "" then 222 | emitError("No output from command: " .. cmd) 223 | return 224 | end 225 | local decoded = vim.fn.json_decode(output) 226 | return decoded 227 | end 228 | return ____exports 229 | -------------------------------------------------------------------------------- /tests/sample/report.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "forbidOnly": false, 4 | "fullyParallel": true, 5 | "globalSetup": null, 6 | "globalTeardown": null, 7 | "globalTimeout": 0, 8 | "grep": {}, 9 | "grepInvert": null, 10 | "maxFailures": 0, 11 | "metadata": {}, 12 | "preserveOutput": "always", 13 | "projects": [ 14 | { 15 | "outputDir": "/home/user/project/test-results", 16 | "repeatEach": 1, 17 | "retries": 0, 18 | "id": "chromium", 19 | "name": "chromium", 20 | "testDir": "/home/user/project/tests", 21 | "testIgnore": [], 22 | "testMatch": ["**/?(*.)@(spec|test).*"], 23 | "timeout": 30000 24 | } 25 | ], 26 | "reporter": [["json", null]], 27 | "reportSlowTests": { 28 | "max": 5, 29 | "threshold": 15000 30 | }, 31 | "configFile": "/home/user/project/playwright.config.ts", 32 | "rootDir": "/home/user/project/tests", 33 | "quiet": false, 34 | "shard": null, 35 | "updateSnapshots": "missing", 36 | "version": "1.30.0", 37 | "workers": 12, 38 | "webServer": null 39 | }, 40 | "suites": [ 41 | { 42 | "title": "example.spec.ts", 43 | "file": "example.spec.ts", 44 | "column": 0, 45 | "line": 0, 46 | "specs": [ 47 | { 48 | "title": "common sense", 49 | "ok": true, 50 | "tags": [], 51 | "tests": [ 52 | { 53 | "timeout": 30000, 54 | "annotations": [], 55 | "expectedStatus": "passed", 56 | "projectId": "chromium", 57 | "projectName": "chromium", 58 | "results": [ 59 | { 60 | "workerIndex": 2, 61 | "status": "passed", 62 | "duration": 64, 63 | "errors": [], 64 | "stdout": [], 65 | "stderr": [], 66 | "retry": 0, 67 | "startTime": "2023-02-06T07:51:39.432Z", 68 | "attachments": [] 69 | } 70 | ], 71 | "status": "expected" 72 | } 73 | ], 74 | "id": "a30a6eba6312f6b87ea5-6c487d38001086d67734", 75 | "file": "example.spec.ts", 76 | "line": 13, 77 | "column": 5 78 | }, 79 | { 80 | "title": "not so common sense", 81 | "ok": false, 82 | "tags": [], 83 | "tests": [ 84 | { 85 | "timeout": 30000, 86 | "annotations": [], 87 | "expectedStatus": "passed", 88 | "projectId": "chromium", 89 | "projectName": "chromium", 90 | "results": [ 91 | { 92 | "workerIndex": 3, 93 | "status": "failed", 94 | "duration": 71, 95 | "error": { 96 | "message": "\u001b[2mexpect(\u001b[22m\u001b[31mreceived\u001b[39m\u001b[2m).\u001b[22mtoBe\u001b[2m(\u001b[22m\u001b[32mexpected\u001b[39m\u001b[2m) // Object.is equality\u001b[22m\n\nExpected: \u001b[32mfalse\u001b[39m\nReceived: \u001b[31mtrue\u001b[39m", 97 | "stack": "Error: \u001b[2mexpect(\u001b[22m\u001b[31mreceived\u001b[39m\u001b[2m).\u001b[22mtoBe\u001b[2m(\u001b[22m\u001b[32mexpected\u001b[39m\u001b[2m) // Object.is equality\u001b[22m\n\nExpected: \u001b[32mfalse\u001b[39m\nReceived: \u001b[31mtrue\u001b[39m\n at /home/user/project/tests/example.spec.ts:18:15" 98 | }, 99 | "errors": [ 100 | { 101 | "location": { 102 | "file": "/home/user/project/tests/example.spec.ts", 103 | "column": 15, 104 | "line": 18 105 | }, 106 | "message": "Error: \u001b[2mexpect(\u001b[22m\u001b[31mreceived\u001b[39m\u001b[2m).\u001b[22mtoBe\u001b[2m(\u001b[22m\u001b[32mexpected\u001b[39m\u001b[2m) // Object.is equality\u001b[22m\n\nExpected: \u001b[32mfalse\u001b[39m\nReceived: \u001b[31mtrue\u001b[39m\n\n\u001b[90m at \u001b[39mexample.spec.ts:18\n\n\u001b[0m \u001b[90m 16 |\u001b[39m\u001b[0m\n\u001b[0m \u001b[90m 17 |\u001b[39m test(\u001b[32m'not so common sense'\u001b[39m\u001b[33m,\u001b[39m \u001b[36masync\u001b[39m ({ page }) \u001b[33m=>\u001b[39m {\u001b[0m\n\u001b[0m\u001b[31m\u001b[1m>\u001b[22m\u001b[39m\u001b[90m 18 |\u001b[39m \texpect(\u001b[36mtrue\u001b[39m)\u001b[33m.\u001b[39mtoBe(\u001b[36mfalse\u001b[39m)\u001b[33m;\u001b[39m\u001b[0m\n\u001b[0m \u001b[90m |\u001b[39m \t \u001b[31m\u001b[1m^\u001b[22m\u001b[39m\u001b[0m\n\u001b[0m \u001b[90m 19 |\u001b[39m })\u001b[33m;\u001b[39m\u001b[0m\n\u001b[0m \u001b[90m 20 |\u001b[39m\u001b[0m\n\n\u001b[2m at /home/user/project/tests/example.spec.ts:18:15\u001b[22m" 107 | } 108 | ], 109 | "stdout": [], 110 | "stderr": [], 111 | "retry": 0, 112 | "startTime": "2023-02-06T07:51:39.432Z", 113 | "attachments": [], 114 | "errorLocation": { 115 | "file": "/home/user/project/tests/example.spec.ts", 116 | "column": 15, 117 | "line": 18 118 | } 119 | } 120 | ], 121 | "status": "unexpected" 122 | } 123 | ], 124 | "id": "a30a6eba6312f6b87ea5-184d3d3d7a2199f803a2", 125 | "file": "example.spec.ts", 126 | "line": 17, 127 | "column": 5 128 | } 129 | ], 130 | "suites": [ 131 | { 132 | "title": "math", 133 | "file": "example.spec.ts", 134 | "line": 3, 135 | "column": 6, 136 | "specs": [ 137 | { 138 | "title": "addition", 139 | "ok": true, 140 | "tags": [], 141 | "tests": [ 142 | { 143 | "timeout": 30000, 144 | "annotations": [], 145 | "expectedStatus": "passed", 146 | "projectId": "chromium", 147 | "projectName": "chromium", 148 | "results": [ 149 | { 150 | "workerIndex": 0, 151 | "status": "passed", 152 | "duration": 66, 153 | "errors": [], 154 | "stdout": [], 155 | "stderr": [], 156 | "retry": 0, 157 | "startTime": "2023-02-06T07:51:39.429Z", 158 | "attachments": [] 159 | } 160 | ], 161 | "status": "expected" 162 | } 163 | ], 164 | "id": "a30a6eba6312f6b87ea5-bce34a10a758cb89be1c", 165 | "file": "example.spec.ts", 166 | "line": 4, 167 | "column": 6 168 | }, 169 | { 170 | "title": "not substraction", 171 | "ok": false, 172 | "tags": [], 173 | "tests": [ 174 | { 175 | "timeout": 30000, 176 | "annotations": [], 177 | "expectedStatus": "passed", 178 | "projectId": "chromium", 179 | "projectName": "chromium", 180 | "results": [ 181 | { 182 | "workerIndex": 1, 183 | "status": "failed", 184 | "duration": 35, 185 | "error": { 186 | "message": "\u001b[2mexpect(\u001b[22m\u001b[31mreceived\u001b[39m\u001b[2m).\u001b[22mtoBe\u001b[2m(\u001b[22m\u001b[32mexpected\u001b[39m\u001b[2m) // Object.is equality\u001b[22m\n\nExpected: \u001b[32m11\u001b[39m\nReceived: \u001b[31m0\u001b[39m", 187 | "stack": "Error: \u001b[2mexpect(\u001b[22m\u001b[31mreceived\u001b[39m\u001b[2m).\u001b[22mtoBe\u001b[2m(\u001b[22m\u001b[32mexpected\u001b[39m\u001b[2m) // Object.is equality\u001b[22m\n\nExpected: \u001b[32m11\u001b[39m\nReceived: \u001b[31m0\u001b[39m\n at /home/user/project/tests/example.spec.ts:9:17" 188 | }, 189 | "errors": [ 190 | { 191 | "location": { 192 | "file": "/home/user/project/tests/example.spec.ts", 193 | "column": 17, 194 | "line": 9 195 | }, 196 | "message": "Error: \u001b[2mexpect(\u001b[22m\u001b[31mreceived\u001b[39m\u001b[2m).\u001b[22mtoBe\u001b[2m(\u001b[22m\u001b[32mexpected\u001b[39m\u001b[2m) // Object.is equality\u001b[22m\n\nExpected: \u001b[32m11\u001b[39m\nReceived: \u001b[31m0\u001b[39m\n\n\u001b[90m at \u001b[39mexample.spec.ts:9\n\n\u001b[0m \u001b[90m 7 |\u001b[39m\u001b[0m\n\u001b[0m \u001b[90m 8 |\u001b[39m \ttest(\u001b[32m'not substraction'\u001b[39m\u001b[33m,\u001b[39m \u001b[36masync\u001b[39m ({ page }) \u001b[33m=>\u001b[39m {\u001b[0m\n\u001b[0m\u001b[31m\u001b[1m>\u001b[22m\u001b[39m\u001b[90m 9 |\u001b[39m \t\texpect(\u001b[35m1\u001b[39m \u001b[33m-\u001b[39m \u001b[35m1\u001b[39m)\u001b[33m.\u001b[39mtoBe(\u001b[35m11\u001b[39m)\u001b[33m;\u001b[39m\u001b[0m\n\u001b[0m \u001b[90m |\u001b[39m \t\t \u001b[31m\u001b[1m^\u001b[22m\u001b[39m\u001b[0m\n\u001b[0m \u001b[90m 10 |\u001b[39m \t})\u001b[33m;\u001b[39m\u001b[0m\n\u001b[0m \u001b[90m 11 |\u001b[39m })\u001b[33m;\u001b[39m\u001b[0m\n\u001b[0m \u001b[90m 12 |\u001b[39m\u001b[0m\n\n\u001b[2m at /home/user/project/tests/example.spec.ts:9:17\u001b[22m" 197 | } 198 | ], 199 | "stdout": [], 200 | "stderr": [], 201 | "retry": 0, 202 | "startTime": "2023-02-06T07:51:39.429Z", 203 | "attachments": [], 204 | "errorLocation": { 205 | "file": "/home/user/project/tests/example.spec.ts", 206 | "column": 17, 207 | "line": 9 208 | } 209 | } 210 | ], 211 | "status": "unexpected" 212 | } 213 | ], 214 | "id": "a30a6eba6312f6b87ea5-186793ad93ea839c6213", 215 | "file": "example.spec.ts", 216 | "line": 8, 217 | "column": 6 218 | } 219 | ] 220 | } 221 | ] 222 | } 223 | ], 224 | "errors": [] 225 | } 226 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # neotest-playwright 2 | 3 | A [playwright](https://playwright.dev/) adapter for [neotest](https://github.com/nvim-neotest/neotest). 4 | 5 | Written in typescript and transpiled to Lua using [tstl](https://github.com/TypeScriptToLua/TypeScriptToLua). 6 | 7 | ## Features 8 | 9 | - 🎭 Discover, run, and parse the output of playwright tests 10 | - ⌨️ Quick launch test attachments ( 🕵️ trace, 📼 video) 11 | - 💅 Project selection + persistence 12 | - ⚙️ On-the-fly presets 13 | 14 | --- 15 | 16 | ## Demo 17 | 18 | https://user-images.githubusercontent.com/33713262/233094989-4073e15f-e72d-4356-9c26-021ca95aa7fd.mp4 19 | 20 | --- 21 | 22 | ## Table of contents 23 | 24 | 25 | 26 | - [neotest-playwright](#neotest-playwright) 27 | - [Features](#features) 28 | - [Demo](#demo) 29 | - [Table of contents](#table-of-contents) 30 | - [Installation](#installation) 31 | - [Configuration](#configuration) 32 | - [Projects](#projects) 33 | - [Presets](#presets) 34 | - [`headed`](#headed) 35 | - [`debug`](#debug) 36 | - [`none`](#none) 37 | - [Dynamic Test Discovery](#dynamic-test-discovery) 38 | - [Caveats](#caveats) 39 | - [Consumers](#consumers) 40 | - [Attachment](#attachment) 41 | - [Consumers Configuration](#consumers-configuration) 42 | - [Performance](#performance) 43 | - [Troubleshooting](#troubleshooting) 44 | - [Credits](#credits) 45 | 46 | 47 | ## Installation 48 | 49 | Using lazyvim: 50 | 51 | ```lua 52 | { 53 | 'nvim-neotest/neotest', 54 | dependencies = { 55 | 'thenbe/neotest-playwright', 56 | dependencies = 'nvim-telescope/telescope.nvim', 57 | }, 58 | config = function() 59 | require('neotest').setup({ 60 | adapters = { 61 | require('neotest-playwright').adapter({ 62 | options = { 63 | persist_project_selection = true, 64 | enable_dynamic_test_discovery = true, 65 | }, 66 | }), 67 | }, 68 | }) 69 | end, 70 | } 71 | ``` 72 | 73 | ## Configuration 74 | 75 | All configuration options are optional. Default values are shown below. 76 | 77 | ```lua 78 | require('neotest-playwright').adapter({ 79 | options = { 80 | persist_project_selection = false, 81 | 82 | enable_dynamic_test_discovery = false, 83 | 84 | preset = 'none', -- "none" | "headed" | "debug" 85 | 86 | get_playwright_binary = function() 87 | return vim.loop.cwd() .. '/node_modules/.bin/playwright' 88 | end, 89 | 90 | get_playwright_config = function() 91 | return vim.loop.cwd() .. '/playwright.config.ts' 92 | end, 93 | 94 | -- Controls the location of the spawned test process. Has no affect on 95 | -- neither the location of the binary nor the location of the playwright 96 | -- config file. 97 | get_cwd = function() 98 | return vim.loop.cwd() 99 | end, 100 | 101 | env = {}, 102 | 103 | -- Extra args to always passed to playwright. These are merged with any 104 | -- extra_args passed to neotest's run command. 105 | extra_args = {}, 106 | 107 | -- Filter directories when searching for test files. Useful in large 108 | -- projects (see performance notes). 109 | filter_dir = function(name, rel_path, root) 110 | return name ~= 'node_modules' 111 | end, 112 | 113 | -- Custom criteria for a file path to be a test file. Useful in large 114 | -- projects or projects with peculiar tests folder structure. IMPORTANT: 115 | -- When setting this option, make sure to be as strict as possible. For 116 | -- example, the pattern should not return true for jpg files that may end up 117 | -- in your test directory. 118 | is_test_file = function(file_path) 119 | -- By default, only returns true if a file contains one of several file 120 | -- extension patterns. See default implementation here: https://github.com/thenbe/neotest-playwright/blob/53c7c9ad8724a6ee7d708c1224f9ea25fa071b61/src/discover.ts#L25-L47 121 | local result = file_path:find('%.test%.[tj]sx?$') ~= nil or file_path:find('%.spec%.[tj]sx?$') ~= nil 122 | -- Alternative example: Match only files that end in `test.ts` 123 | local result = file_path:find('%.test%.ts$') ~= nil 124 | -- Alternative example: Match only files that end in `test.ts`, but only if it has ancestor directory `e2e/tests` 125 | local result = file_path:find('e2e/tests/.*%.test%.ts$') ~= nil 126 | return result 127 | end, 128 | 129 | experimental = { 130 | telescope = { 131 | -- If true, a telescope picker will be used for `:NeotestPlaywrightProject`. 132 | -- Otherwise, `vim.ui.select` is used. 133 | -- In normal mode, `` toggles the project under the cursor. 134 | -- `` (enter key) applies the selection. 135 | enabled = false, 136 | opts = {}, 137 | }, 138 | }, 139 | }, 140 | }) 141 | ``` 142 | 143 | --- 144 | 145 | ## Projects 146 | 147 | `neotest-playwright` allows you to conveniently toggle your playwright [Projects](https://playwright.dev/docs/test-advanced#projects) on and off. To activate (or deactivate) a project, use the `:NeotestPlaywrightProject` command. `neotest-playwright` will only include the projects you've activated in any subsequent playwright commands (using the `--project` flag). Your selection will persist until you either change it with `:NeotestPlaywrightProject`, or restart neovim. 148 | 149 | If you wish, you can choose to persist your project selection across neovim sessions by setting `persist_project_selection` to true (see example). Selection data is keyed by the project's root directory, meaning you can persist multiple distinct selections across different projects (or git worktrees). 150 | 151 | [![asciicast](https://asciinema.org/a/558555.svg)](https://asciinema.org/a/558555) 152 | 153 | --- 154 | 155 | ## Presets 156 | 157 | Presets can help you debug your tests on the fly. A preset is just a group of command line flags that come in handy in common scenarios. 158 | 159 | > To select a preset, use the `:NeotestPlaywrightPreset` command. Once a preset is selected, it remains active until you either select another preset, clear it by selecting the `none` preset, or restart neovim. 160 | 161 | ### `headed` 162 | 163 | > Applies the following flags: `--headed --retries 0 --timeout 0 --workers 1 --max-failures 0` 164 | 165 | Runs tests in headed mode. 166 | 167 | > 💡 Tip: Use with [`await page.pause()`](https://playwright.dev/docs/api/class-page#page-pause) to open the playwright inspector and debug your locators. 168 | 169 | ### `debug` 170 | 171 | > Applies the following flags: `--debug` 172 | 173 | Playwright uses the `--debug` flag as a shortcut for multiple options. See [here](https://playwright.dev/docs/test-cli#reference) for more information. 174 | 175 | ### `none` 176 | 177 | Does not apply any flags. Your tests will run as defined in your `playwright.config.ts` file. 178 | 179 | --- 180 | 181 | ## Dynamic Test Discovery 182 | 183 | `neotest-playwright` can make use of the `playwright` cli to unlock extra features. Most importantly, the `playwright` cli provides information about which tests belongs to which project. `neotest-playwright` will parse this information to display, run, and report the results of tests on a per-project basis. 184 | 185 | To enable this, set `enable_dynamic_test_discovery` to true. 186 | 187 | ### Caveats 188 | 189 | This feature works by calling `playwright test --list --reporter=json`. While this is a relatively fast operation, it does add some overhead. Therefore, `neotest-playwright` only calls this feature once (when the adapter is first initialized). From then on, `neotest-playwright` continues to rely on treesitter to track your tests and enhance them with the data previously resolved by the `playwright` cli. There are times, however, where we want to refresh this data. To remedy this: `neotest-playwright` exposes a command `:NeotestPlaywrightRefresh`. This comes in handy in the following scenarios: 190 | 191 | - Adding a new test 192 | - Renaming a test 193 | - Changing the project(s) configuration in your `playwright.config.ts` file 194 | 195 | ## Consumers 196 | 197 | ### Attachment 198 | 199 | Displays the attachments for the test under the cursor. Upon selection, the attachment is launched. 200 | 201 | https://user-images.githubusercontent.com/33713262/231016415-d110f491-290e-46e3-a118-b3d4802723ca.mp4 202 | 203 | ### Consumers Configuration 204 | 205 | > Requires `enable_dynamic_test_discovery = true`. 206 | 207 | 1. Include the consumer in your `neotest` setup: 208 | 209 | ```lua 210 | require('neotest').setup({ 211 | consumers = { 212 | -- add to your list of consumers 213 | playwright = require('neotest-playwright.consumers').consumers, 214 | }, 215 | }) 216 | ``` 217 | 218 | 2. Add keybinding: 219 | 220 | ```lua 221 | { 222 | 'thenbe/neotest-playwright', 223 | keys = { 224 | { 225 | 'ta', 226 | function() 227 | require('neotest').playwright.attachment() 228 | end, 229 | desc = 'Launch test attachment', 230 | }, 231 | }, 232 | } 233 | ``` 234 | 235 | ## Performance 236 | 237 | Use `filter_dir` option to limit directories to be searched for tests. 238 | 239 | ```lua 240 | ---Filter directories when searching for test files 241 | ---@async 242 | ---@param name string Name of directory 243 | ---@param rel_path string Path to directory, relative to root 244 | ---@param root string Root directory of project 245 | ---@return boolean 246 | filter_dir = function(name, rel_path, root) 247 | local full_path = root .. '/' .. rel_path 248 | 249 | if root:match('projects/my-large-monorepo') then 250 | if full_path:match('^packages/site/test') then 251 | return true 252 | else 253 | return false 254 | end 255 | else 256 | return name ~= 'node_modules' 257 | end 258 | end 259 | ``` 260 | 261 | ## Troubleshooting 262 | 263 | [`testDir`](https://playwright.dev/docs/api/class-testconfig#test-config-test-dir) should be defined in `playwright.config.ts`. 264 | 265 | ### Error: Project(s) "foo" not found. Available named projects: "bar", "baz" 266 | 267 | - Run `:NeotestPlaywrightProject`. Once you apply your selection, any old phantom project names will be cleared from the state file. 268 | - You may also delete the state file manually. Find the state file's path by running `:lua =vim.fn.stdpath('data') .. '/neotest-playwright.json'`. 269 | 270 | ## Credits 271 | 272 | - [neotest-jest](https://github.com/haydenmeade/neotest-jest) 273 | -------------------------------------------------------------------------------- /src/types/neotest.d.ts: -------------------------------------------------------------------------------- 1 | declare function print(...args: unknown[]): void; 2 | 3 | type MatchType = 'namespace' | 'test'; 4 | 5 | type NodeMatch = { 6 | [K in `${T}.name` | `${T}.definition`]: LuaUserdata; 7 | }; 8 | 9 | type Range = LuaMultiReturn<[number, number, number, number]>; 10 | 11 | declare module 'neotest' { 12 | interface RangelessPosition { 13 | id?: string; 14 | type: 'dir' | 'file' | 'namespace' | 'test'; 15 | name: string; 16 | path: string; 17 | project_id?: string; 18 | } 19 | 20 | interface RangedPosition extends RangelessPosition { 21 | /** [start_row, start_col, end_row, end_col] */ 22 | range: Range; 23 | } 24 | 25 | type Position = RangedPosition | RangelessPosition; 26 | 27 | type BuildPosition = ( 28 | this: void, 29 | file_path: string, 30 | source: string, 31 | captured_nodes: NodeMatch, 32 | ) => Position | Position[]; 33 | 34 | type PositionId = (position: Position, parents: Position[]) => string; 35 | 36 | interface RunArgs { 37 | extra_args?: string[]; 38 | strategy: string; 39 | tree: Tree; 40 | } 41 | 42 | /** The context used by neotest-jest */ 43 | interface Context { 44 | results_path: string; 45 | file: string; 46 | } 47 | 48 | interface RunSpec { 49 | command: string[]; 50 | env?: Record; 51 | cwd?: string | null; 52 | /* Arbitrary data to preserve state between running and result collection */ 53 | context: Context; 54 | /** Arguments for strategy */ 55 | strategy?: Record; 56 | stream?: (output_stream: () => string[]) => () => Record; 57 | } 58 | 59 | interface StrategyResult { 60 | code: number; 61 | output: string; 62 | } 63 | 64 | interface Result { 65 | status: 'passed' | 'failed' | 'skipped'; 66 | /** Path to file containing full output data */ 67 | output?: string; 68 | /** Shortened output string */ 69 | short: string; 70 | errors: Error[]; 71 | } 72 | 73 | interface Error { 74 | message: string; 75 | line?: number; 76 | // column?: number; // exists? 77 | } 78 | 79 | /** The key should be the treesitter id of the node. 80 | * @example "path/to/file::Describe text::test text" 81 | */ 82 | type ResultKey = string; 83 | 84 | type Results = Record; 85 | 86 | /** 87 | * @class neotest.Adapter 88 | * @property {string} name 89 | */ 90 | /** @noSelf **/ 91 | interface Adapter { 92 | name: string; 93 | 94 | /** 95 | * See :h lspconfig-root-dir 96 | * 97 | * Find the project root directory given a current directory to work from. 98 | * Should no root be found, the adapter can still be used in a non-project context if a test file matches. 99 | * 100 | * @async 101 | * @param {string} dir - Directory to treat as cwd 102 | * @returns {string | undefined} - Absolute root dir of test suite 103 | */ 104 | root(dir: string): string | undefined; 105 | 106 | /** 107 | * Filter directories when searching for test files 108 | * 109 | * @async 110 | * @param {string} name - Name of directory 111 | * @param {string} rel_path - Path to directory, relative to root 112 | * @param {string} root - Root directory of project 113 | * @returns {boolean} 114 | */ 115 | filter_dir(name: string, rel_path: string, root: string): boolean; 116 | 117 | /** 118 | * @async 119 | * @param {string} file_path 120 | * @returns {boolean} 121 | */ 122 | is_test_file(file_path: string): boolean; 123 | 124 | /** 125 | * Given a file path, parse all the tests within it. 126 | * 127 | * @async 128 | * @param {string} file_path - Absolute file path 129 | * @returns {Tree | undefined} 130 | */ 131 | discover_positions(path: string): Tree | undefined; 132 | 133 | /** 134 | * @param {RunArgs} args 135 | * @returns {RunSpec | RunSpec[] | undefined} 136 | */ 137 | build_spec(args: RunArgs): RunSpec | RunSpec[] | undefined; 138 | 139 | /** 140 | * @async 141 | * @param {RunSpec} spec 142 | * @param {StrategyResult} result 143 | * @param {Tree} tree 144 | * @returns {Record} 145 | */ 146 | results( 147 | spec: RunSpec, 148 | result: StrategyResult, 149 | tree: Tree, 150 | ): Record; 151 | } 152 | 153 | /* Nested tree structure with nodes containing data and having any number of 154 | * children */ 155 | class Tree { 156 | /** Create a new tree node */ 157 | constructor( 158 | /** Node data */ 159 | data: Position, 160 | 161 | /** Children of this node */ 162 | children: Tree[], 163 | 164 | /** Function to generate a key from the node data */ 165 | key: (data: unknown) => string, 166 | 167 | /** Parent of this node */ 168 | parent?: Tree, 169 | 170 | /** Nodes of this tree */ 171 | nodes?: Record, 172 | ); 173 | 174 | /** Parses a tree in the shape of nested lists. 175 | * The head of the list is the root of the tree, and all following elements are its children. */ 176 | static from_list(data: unknown[], key: (data: unknown) => string): Tree; 177 | 178 | data(): Position; 179 | children(): Tree[]; 180 | nodes(): Record; 181 | key(): (data: unknown) => string; 182 | parent(): Tree | undefined; 183 | 184 | get_key(key: unknown): Tree | null; 185 | set_key(key: unknown, value: Tree): void; 186 | iter_parents(): IterableIterator; 187 | 188 | /** Fetch the first node ascending the tree (including the current one) 189 | * with the given data */ 190 | closest_node_with(data_attr: string): Tree | null; 191 | 192 | /** Fetch the first non-nil value for the given data attribute ascending the 193 | tree (including the current node) with the given data attribute. */ 194 | closest_value_for(data_attr: string): unknown | null; 195 | } 196 | } 197 | 198 | declare module 'neotest.async' { 199 | namespace fn { 200 | /** The result is a String, which is the name of a file that doesn't exist. 201 | * It can be used for a temporary file. 202 | * 203 | * Proxy for `vim.fn.tempname()` 204 | */ 205 | const tempname: () => string; 206 | } 207 | 208 | const run: (this: void, callback: () => void) => void; 209 | } 210 | 211 | declare module 'neotest.lib' { 212 | namespace treesitter { 213 | /** Read a file's contents from disk and parse test positions using the 214 | * given query. Executed in a subprocess to avoid blocking the editor if 215 | * possible. Since functions can't be serialised for RPC the build_position and 216 | * position_id options can be strings that will evaluate to globally 217 | * referencable functions (e.g. `'require("my_adapter")._build_position'`). */ 218 | const parse_positions: ( 219 | this: void, 220 | path: string, 221 | query: string, 222 | opts: ParseOptions, 223 | ) => import('neotest').Tree; 224 | 225 | interface ParseOptions { 226 | /** Allow nested tests */ 227 | nested_tests?: boolean; 228 | /** Require tests to be within namespaces */ 229 | require_namespaces?: boolean; 230 | /** Position ID constructor */ 231 | 232 | /** Return a string that will evaluate to a globally referencable function. 233 | * e.g. `'require("my_adapter")._position_id'` */ 234 | position_id?: PositionId | string; 235 | 236 | /** Return a string that will evaluate to a globally referencable function. 237 | * e.g. `'require("my_adapter")._build_position'` 238 | * 239 | * https://github.com/nvim-neotest/neotest/issues/68#issuecomment-1242769159 */ 240 | build_position?: BuildPosition | string; 241 | } 242 | } 243 | 244 | namespace files { 245 | /** 246 | * Create a function that will take directory and attempt to match the 247 | * provided glob patterns against the contents of the directory. 248 | * 249 | * @param ... string Patterns to match e.g "*.py" 250 | * @return fun(path: string): string | nil 251 | */ 252 | const match_root_pattern: ( 253 | ...patterns: string[] 254 | ) => (path: string) => string | undefined; 255 | 256 | const exists: (path: string) => boolean | string; 257 | 258 | /** Read a file asynchronously */ 259 | const read: (file_path: string) => string | string; 260 | 261 | const write: (file_path: string, data: string) => boolean | string; 262 | 263 | /** Streams data from a file, watching for new data over time. Only works 264 | * when data is exclusively added and not deleted from the file. Useful for 265 | * watching a file which is written to by another process. 266 | * 267 | * Source: https://github.com/nvim-neotest/neotest/blob/master/lua/neotest/lib/file/init.lua#L93-L144 268 | */ 269 | const stream: ( 270 | file_path: string, 271 | ) => LuaMultiReturn<[() => string, () => void]>; 272 | } 273 | 274 | namespace subprocess { 275 | /** Wrapper around vim.fn.rpcrequest that will automatically select the channel for the child or parent process, 276 | * depending on if the current instance is the child or parent. 277 | * See `:help rpcrequest` for more information. */ 278 | const request: ( 279 | method: string, 280 | ...args: unknown[] 281 | ) => LuaMultiReturn<[unknown, string?]>; 282 | 283 | /** Wrapper around vim.fn.rpcnotify that will automatically select the channel for the child or parent process, 284 | * depending on if the current instance is the child or parent. 285 | * See `:help rpcnotify` for more information. */ 286 | const notify: (method: string, ...args: unknown[]) => void; 287 | 288 | /** Call a lua function in the other process with the given argument list, returning the result. */ 289 | const call: ( 290 | func: string, 291 | args?: unknown[], 292 | ) => LuaMultiReturn<[unknown, string?]>; 293 | 294 | /** Check if the subprocess has been initialized and is working */ 295 | const enabled: () => boolean; 296 | 297 | /** Check if the current neovim instance is the child or parent process */ 298 | const is_child: () => boolean; 299 | } 300 | } 301 | 302 | declare module 'neotest.logging' { 303 | const trace: (message: string, cause?: unknown) => void; 304 | const debug: (message: string, cause?: unknown) => void; 305 | const info: (message: string, cause?: unknown) => void; 306 | const warn: (message: string, cause?: unknown) => void; 307 | const error: (message: string, cause?: unknown) => void; 308 | } 309 | -------------------------------------------------------------------------------- /lua/neotest-playwright/position.lua: -------------------------------------------------------------------------------- 1 | --[[ Generated with https://github.com/TypeScriptToLua/TypeScriptToLua ]] 2 | -- Lua Library inline imports 3 | local function __TS__StringIncludes(self, searchString, position) 4 | if not position then 5 | position = 1 6 | else 7 | position = position + 1 8 | end 9 | local index = string.find(self, searchString, position, true) 10 | return index ~= nil 11 | end 12 | 13 | local function __TS__New(target, ...) 14 | local instance = setmetatable({}, target.prototype) 15 | instance:____constructor(...) 16 | return instance 17 | end 18 | 19 | local function __TS__Class(self) 20 | local c = {prototype = {}} 21 | c.prototype.__index = c.prototype 22 | c.prototype.constructor = c 23 | return c 24 | end 25 | 26 | local function __TS__ClassExtends(target, base) 27 | target.____super = base 28 | local staticMetatable = setmetatable({__index = base}, base) 29 | setmetatable(target, staticMetatable) 30 | local baseMetatable = getmetatable(base) 31 | if baseMetatable then 32 | if type(baseMetatable.__index) == "function" then 33 | staticMetatable.__index = baseMetatable.__index 34 | end 35 | if type(baseMetatable.__newindex) == "function" then 36 | staticMetatable.__newindex = baseMetatable.__newindex 37 | end 38 | end 39 | setmetatable(target.prototype, base.prototype) 40 | if type(base.prototype.__index) == "function" then 41 | target.prototype.__index = base.prototype.__index 42 | end 43 | if type(base.prototype.__newindex) == "function" then 44 | target.prototype.__newindex = base.prototype.__newindex 45 | end 46 | if type(base.prototype.__tostring) == "function" then 47 | target.prototype.__tostring = base.prototype.__tostring 48 | end 49 | end 50 | 51 | local Error, RangeError, ReferenceError, SyntaxError, TypeError, URIError 52 | do 53 | local function getErrorStack(self, constructor) 54 | local level = 1 55 | while true do 56 | local info = debug.getinfo(level, "f") 57 | level = level + 1 58 | if not info then 59 | level = 1 60 | break 61 | elseif info.func == constructor then 62 | break 63 | end 64 | end 65 | if __TS__StringIncludes(_VERSION, "Lua 5.0") then 66 | return debug.traceback(("[Level " .. tostring(level)) .. "]") 67 | else 68 | return debug.traceback(nil, level) 69 | end 70 | end 71 | local function wrapErrorToString(self, getDescription) 72 | return function(self) 73 | local description = getDescription(self) 74 | local caller = debug.getinfo(3, "f") 75 | local isClassicLua = __TS__StringIncludes(_VERSION, "Lua 5.0") or _VERSION == "Lua 5.1" 76 | if isClassicLua or caller and caller.func ~= error then 77 | return description 78 | else 79 | return (description .. "\n") .. tostring(self.stack) 80 | end 81 | end 82 | end 83 | local function initErrorClass(self, Type, name) 84 | Type.name = name 85 | return setmetatable( 86 | Type, 87 | {__call = function(____, _self, message) return __TS__New(Type, message) end} 88 | ) 89 | end 90 | local ____initErrorClass_1 = initErrorClass 91 | local ____class_0 = __TS__Class() 92 | ____class_0.name = "" 93 | function ____class_0.prototype.____constructor(self, message) 94 | if message == nil then 95 | message = "" 96 | end 97 | self.message = message 98 | self.name = "Error" 99 | self.stack = getErrorStack(nil, self.constructor.new) 100 | local metatable = getmetatable(self) 101 | if metatable and not metatable.__errorToStringPatched then 102 | metatable.__errorToStringPatched = true 103 | metatable.__tostring = wrapErrorToString(nil, metatable.__tostring) 104 | end 105 | end 106 | function ____class_0.prototype.__tostring(self) 107 | return self.message ~= "" and (self.name .. ": ") .. self.message or self.name 108 | end 109 | Error = ____initErrorClass_1(nil, ____class_0, "Error") 110 | local function createErrorClass(self, name) 111 | local ____initErrorClass_3 = initErrorClass 112 | local ____class_2 = __TS__Class() 113 | ____class_2.name = ____class_2.name 114 | __TS__ClassExtends(____class_2, Error) 115 | function ____class_2.prototype.____constructor(self, ...) 116 | ____class_2.____super.prototype.____constructor(self, ...) 117 | self.name = name 118 | end 119 | return ____initErrorClass_3(nil, ____class_2, name) 120 | end 121 | RangeError = createErrorClass(nil, "RangeError") 122 | ReferenceError = createErrorClass(nil, "ReferenceError") 123 | SyntaxError = createErrorClass(nil, "SyntaxError") 124 | TypeError = createErrorClass(nil, "TypeError") 125 | URIError = createErrorClass(nil, "URIError") 126 | end 127 | 128 | local function __TS__ArrayFilter(self, callbackfn, thisArg) 129 | local result = {} 130 | local len = 0 131 | for i = 1, #self do 132 | if callbackfn(thisArg, self[i], i - 1, self) then 133 | len = len + 1 134 | result[len] = self[i] 135 | end 136 | end 137 | return result 138 | end 139 | 140 | local function __TS__ObjectAssign(target, ...) 141 | local sources = {...} 142 | for i = 1, #sources do 143 | local source = sources[i] 144 | for key in pairs(source) do 145 | target[key] = source[key] 146 | end 147 | end 148 | return target 149 | end 150 | 151 | local function __TS__ArrayIncludes(self, searchElement, fromIndex) 152 | if fromIndex == nil then 153 | fromIndex = 0 154 | end 155 | local len = #self 156 | local k = fromIndex 157 | if fromIndex < 0 then 158 | k = len + fromIndex 159 | end 160 | if k < 0 then 161 | k = 0 162 | end 163 | for i = k + 1, len do 164 | if self[i] == searchElement then 165 | return true 166 | end 167 | end 168 | return false 169 | end 170 | 171 | local function __TS__ArrayMap(self, callbackfn, thisArg) 172 | local result = {} 173 | for i = 1, #self do 174 | result[i] = callbackfn(thisArg, self[i], i - 1, self) 175 | end 176 | return result 177 | end 178 | 179 | local function __TS__ObjectRest(target, usedProperties) 180 | local result = {} 181 | for property in pairs(target) do 182 | if not usedProperties[property] then 183 | result[property] = target[property] 184 | end 185 | end 186 | return result 187 | end 188 | -- End of Lua Library inline imports 189 | local ____exports = {} 190 | local specToPosition 191 | local ____adapter_2Ddata = require('neotest-playwright.adapter-data') 192 | local data = ____adapter_2Ddata.data 193 | local ____adapter_2Doptions = require('neotest-playwright.adapter-options') 194 | local options = ____adapter_2Doptions.options 195 | local ____helpers = require('neotest-playwright.helpers') 196 | local emitError = ____helpers.emitError 197 | local ____logging = require('neotest-playwright.logging') 198 | local logger = ____logging.logger 199 | --- Given a test position, return one or more positions based on what can be 200 | -- dynamically discovered using the playwright cli. 201 | ____exports.buildTestPosition = function(basePosition) 202 | local line = basePosition.range[1] 203 | if not data.specs then 204 | error( 205 | __TS__New(Error, "No specs found"), 206 | 0 207 | ) 208 | end 209 | local specs = __TS__ArrayFilter( 210 | data.specs, 211 | function(____, spec) 212 | local specAbsolutePath = (tostring(data.rootDir) .. "/") .. spec.file 213 | local fileMatch = specAbsolutePath == basePosition.path 214 | if not fileMatch then 215 | return false 216 | end 217 | local rowMatch = spec.line == line + 1 218 | local match = rowMatch and fileMatch 219 | return match 220 | end 221 | ) 222 | if #specs == 0 then 223 | logger("debug", "No match found") 224 | return {basePosition} 225 | end 226 | local projects = options.projects 227 | local positions = {} 228 | --- The parent of the range-less positions 229 | local main = __TS__ObjectAssign({}, basePosition) 230 | positions[#positions + 1] = main 231 | __TS__ArrayMap( 232 | specs, 233 | function(____, spec) 234 | local position = specToPosition(spec, basePosition) 235 | if #options.projects == 0 then 236 | positions[#positions + 1] = position 237 | return 238 | else 239 | local projectId = position.project_id 240 | if not projectId then 241 | local msg = "No project id found for position: " .. position.name 242 | emitError(msg) 243 | error( 244 | __TS__New(Error, msg), 245 | 0 246 | ) 247 | end 248 | if __TS__ArrayIncludes(projects, projectId) then 249 | positions[#positions + 1] = position 250 | end 251 | end 252 | end 253 | ) 254 | return positions 255 | end 256 | --- Convert a playwright spec to a neotest position. 257 | specToPosition = function(spec, basePosition) 258 | local ____opt_0 = spec.tests[1] 259 | local projectId = ____opt_0 and ____opt_0.projectName 260 | if not projectId then 261 | local msg = "No project id found for spec: " .. spec.title 262 | emitError(msg) 263 | error( 264 | __TS__New(Error, msg), 265 | 0 266 | ) 267 | end 268 | local ____basePosition_2 = basePosition 269 | local range = ____basePosition_2.range 270 | local rest = __TS__ObjectRest(____basePosition_2, {range = true}) 271 | local position = __TS__ObjectAssign({}, rest, {id = spec.id, name = projectId, project_id = projectId}) 272 | return position 273 | end 274 | return ____exports 275 | -------------------------------------------------------------------------------- /lua/neotest-playwright/select-multiple.lua: -------------------------------------------------------------------------------- 1 | --[[ Generated with https://github.com/TypeScriptToLua/TypeScriptToLua ]] 2 | -- Lua Library inline imports 3 | local function __TS__Class(self) 4 | local c = {prototype = {}} 5 | c.prototype.__index = c.prototype 6 | c.prototype.constructor = c 7 | return c 8 | end 9 | 10 | local __TS__Symbol, Symbol 11 | do 12 | local symbolMetatable = {__tostring = function(self) 13 | return ("Symbol(" .. (self.description or "")) .. ")" 14 | end} 15 | function __TS__Symbol(description) 16 | return setmetatable({description = description}, symbolMetatable) 17 | end 18 | Symbol = { 19 | iterator = __TS__Symbol("Symbol.iterator"), 20 | hasInstance = __TS__Symbol("Symbol.hasInstance"), 21 | species = __TS__Symbol("Symbol.species"), 22 | toStringTag = __TS__Symbol("Symbol.toStringTag") 23 | } 24 | end 25 | 26 | local __TS__Iterator 27 | do 28 | local function iteratorGeneratorStep(self) 29 | local co = self.____coroutine 30 | local status, value = coroutine.resume(co) 31 | if not status then 32 | error(value, 0) 33 | end 34 | if coroutine.status(co) == "dead" then 35 | return 36 | end 37 | return true, value 38 | end 39 | local function iteratorIteratorStep(self) 40 | local result = self:next() 41 | if result.done then 42 | return 43 | end 44 | return true, result.value 45 | end 46 | local function iteratorStringStep(self, index) 47 | index = index + 1 48 | if index > #self then 49 | return 50 | end 51 | return index, string.sub(self, index, index) 52 | end 53 | function __TS__Iterator(iterable) 54 | if type(iterable) == "string" then 55 | return iteratorStringStep, iterable, 0 56 | elseif iterable.____coroutine ~= nil then 57 | return iteratorGeneratorStep, iterable 58 | elseif iterable[Symbol.iterator] then 59 | local iterator = iterable[Symbol.iterator](iterable) 60 | return iteratorIteratorStep, iterator 61 | else 62 | return ipairs(iterable) 63 | end 64 | end 65 | end 66 | 67 | local Set 68 | do 69 | Set = __TS__Class() 70 | Set.name = "Set" 71 | function Set.prototype.____constructor(self, values) 72 | self[Symbol.toStringTag] = "Set" 73 | self.size = 0 74 | self.nextKey = {} 75 | self.previousKey = {} 76 | if values == nil then 77 | return 78 | end 79 | local iterable = values 80 | if iterable[Symbol.iterator] then 81 | local iterator = iterable[Symbol.iterator](iterable) 82 | while true do 83 | local result = iterator:next() 84 | if result.done then 85 | break 86 | end 87 | self:add(result.value) 88 | end 89 | else 90 | local array = values 91 | for ____, value in ipairs(array) do 92 | self:add(value) 93 | end 94 | end 95 | end 96 | function Set.prototype.add(self, value) 97 | local isNewValue = not self:has(value) 98 | if isNewValue then 99 | self.size = self.size + 1 100 | end 101 | if self.firstKey == nil then 102 | self.firstKey = value 103 | self.lastKey = value 104 | elseif isNewValue then 105 | self.nextKey[self.lastKey] = value 106 | self.previousKey[value] = self.lastKey 107 | self.lastKey = value 108 | end 109 | return self 110 | end 111 | function Set.prototype.clear(self) 112 | self.nextKey = {} 113 | self.previousKey = {} 114 | self.firstKey = nil 115 | self.lastKey = nil 116 | self.size = 0 117 | end 118 | function Set.prototype.delete(self, value) 119 | local contains = self:has(value) 120 | if contains then 121 | self.size = self.size - 1 122 | local next = self.nextKey[value] 123 | local previous = self.previousKey[value] 124 | if next ~= nil and previous ~= nil then 125 | self.nextKey[previous] = next 126 | self.previousKey[next] = previous 127 | elseif next ~= nil then 128 | self.firstKey = next 129 | self.previousKey[next] = nil 130 | elseif previous ~= nil then 131 | self.lastKey = previous 132 | self.nextKey[previous] = nil 133 | else 134 | self.firstKey = nil 135 | self.lastKey = nil 136 | end 137 | self.nextKey[value] = nil 138 | self.previousKey[value] = nil 139 | end 140 | return contains 141 | end 142 | function Set.prototype.forEach(self, callback) 143 | for ____, key in __TS__Iterator(self:keys()) do 144 | callback(nil, key, key, self) 145 | end 146 | end 147 | function Set.prototype.has(self, value) 148 | return self.nextKey[value] ~= nil or self.lastKey == value 149 | end 150 | Set.prototype[Symbol.iterator] = function(self) 151 | return self:values() 152 | end 153 | function Set.prototype.entries(self) 154 | local nextKey = self.nextKey 155 | local key = self.firstKey 156 | return { 157 | [Symbol.iterator] = function(self) 158 | return self 159 | end, 160 | next = function(self) 161 | local result = {done = not key, value = {key, key}} 162 | key = nextKey[key] 163 | return result 164 | end 165 | } 166 | end 167 | function Set.prototype.keys(self) 168 | local nextKey = self.nextKey 169 | local key = self.firstKey 170 | return { 171 | [Symbol.iterator] = function(self) 172 | return self 173 | end, 174 | next = function(self) 175 | local result = {done = not key, value = key} 176 | key = nextKey[key] 177 | return result 178 | end 179 | } 180 | end 181 | function Set.prototype.values(self) 182 | local nextKey = self.nextKey 183 | local key = self.firstKey 184 | return { 185 | [Symbol.iterator] = function(self) 186 | return self 187 | end, 188 | next = function(self) 189 | local result = {done = not key, value = key} 190 | key = nextKey[key] 191 | return result 192 | end 193 | } 194 | end 195 | Set[Symbol.species] = Set 196 | end 197 | 198 | local function __TS__ArrayIndexOf(self, searchElement, fromIndex) 199 | if fromIndex == nil then 200 | fromIndex = 0 201 | end 202 | local len = #self 203 | if len == 0 then 204 | return -1 205 | end 206 | if fromIndex >= len then 207 | return -1 208 | end 209 | if fromIndex < 0 then 210 | fromIndex = len + fromIndex 211 | if fromIndex < 0 then 212 | fromIndex = 0 213 | end 214 | end 215 | for i = fromIndex + 1, len do 216 | if self[i] == searchElement then 217 | return i - 1 218 | end 219 | end 220 | return -1 221 | end 222 | 223 | local __TS__ArrayFrom 224 | do 225 | local function arrayLikeStep(self, index) 226 | index = index + 1 227 | if index > self.length then 228 | return 229 | end 230 | return index, self[index] 231 | end 232 | local function arrayLikeIterator(arr) 233 | if type(arr.length) == "number" then 234 | return arrayLikeStep, arr, 0 235 | end 236 | return __TS__Iterator(arr) 237 | end 238 | function __TS__ArrayFrom(arrayLike, mapFn, thisArg) 239 | local result = {} 240 | if mapFn == nil then 241 | for ____, v in arrayLikeIterator(arrayLike) do 242 | result[#result + 1] = v 243 | end 244 | else 245 | for i, v in arrayLikeIterator(arrayLike) do 246 | result[#result + 1] = mapFn(thisArg, v, i - 1) 247 | end 248 | end 249 | return result 250 | end 251 | end 252 | 253 | local function __TS__New(target, ...) 254 | local instance = setmetatable({}, target.prototype) 255 | instance:____constructor(...) 256 | return instance 257 | end 258 | -- End of Lua Library inline imports 259 | local ____exports = {} 260 | local determineInitialSelection 261 | --- Uses vim.ui.select to present a list of choices to the user. However, 262 | -- instead of disappearing when the user selects an option, the list remains 263 | -- open and the user can select multiple choices. The user can *keep toggling 264 | -- choices until they are satisfied with their selection. Then they can press 265 | -- enter to close the list and return the selected choices. 266 | -- 267 | -- An asterisk is used to indicate that an option is selected. 268 | -- 269 | -- A final option "done" is added to the list to allow the user to close the list. 270 | ____exports.selectMultiple = function(____bindingPattern0) 271 | local preselected 272 | local initial 273 | local choices 274 | local prompt 275 | prompt = ____bindingPattern0.prompt 276 | choices = ____bindingPattern0.choices 277 | initial = ____bindingPattern0.initial 278 | if initial == nil then 279 | initial = "none" 280 | end 281 | preselected = ____bindingPattern0.preselected 282 | local selected = determineInitialSelection(initial, choices, preselected) 283 | local choice 284 | local done = false 285 | while not done do 286 | vim.ui.select( 287 | choices, 288 | { 289 | prompt = prompt, 290 | format_item = function(item) 291 | return selected:has(item) and "* " .. item or item 292 | end 293 | }, 294 | function(c) 295 | choice = c 296 | end 297 | ) 298 | local index = __TS__ArrayIndexOf(choices, choice) 299 | done = index == -1 300 | if done then 301 | break 302 | elseif selected:has(choice) then 303 | selected:delete(choice) 304 | else 305 | selected:add(choice) 306 | end 307 | vim.cmd("redraw") 308 | end 309 | return __TS__ArrayFrom(selected) 310 | end 311 | determineInitialSelection = function(initial, choices, preselected) 312 | if preselected then 313 | return __TS__New(Set, preselected) 314 | elseif initial == "all" then 315 | return __TS__New(Set, choices) 316 | else 317 | return __TS__New(Set) 318 | end 319 | end 320 | return ____exports 321 | -------------------------------------------------------------------------------- /lua/neotest-playwright/discover.lua: -------------------------------------------------------------------------------- 1 | --[[ Generated with https://github.com/TypeScriptToLua/TypeScriptToLua ]] 2 | -- Lua Library inline imports 3 | local function __TS__StringEndsWith(self, searchString, endPosition) 4 | if endPosition == nil or endPosition > #self then 5 | endPosition = #self 6 | end 7 | return string.sub(self, endPosition - #searchString + 1, endPosition) == searchString 8 | end 9 | 10 | local function __TS__ArraySome(self, callbackfn, thisArg) 11 | for i = 1, #self do 12 | if callbackfn(thisArg, self[i], i - 1, self) then 13 | return true 14 | end 15 | end 16 | return false 17 | end 18 | 19 | local function __TS__ObjectAssign(target, ...) 20 | local sources = {...} 21 | for i = 1, #sources do 22 | local source = sources[i] 23 | for key in pairs(source) do 24 | target[key] = source[key] 25 | end 26 | end 27 | return target 28 | end 29 | 30 | local function __TS__StringIncludes(self, searchString, position) 31 | if not position then 32 | position = 1 33 | else 34 | position = position + 1 35 | end 36 | local index = string.find(self, searchString, position, true) 37 | return index ~= nil 38 | end 39 | 40 | local function __TS__New(target, ...) 41 | local instance = setmetatable({}, target.prototype) 42 | instance:____constructor(...) 43 | return instance 44 | end 45 | 46 | local function __TS__Class(self) 47 | local c = {prototype = {}} 48 | c.prototype.__index = c.prototype 49 | c.prototype.constructor = c 50 | return c 51 | end 52 | 53 | local function __TS__ClassExtends(target, base) 54 | target.____super = base 55 | local staticMetatable = setmetatable({__index = base}, base) 56 | setmetatable(target, staticMetatable) 57 | local baseMetatable = getmetatable(base) 58 | if baseMetatable then 59 | if type(baseMetatable.__index) == "function" then 60 | staticMetatable.__index = baseMetatable.__index 61 | end 62 | if type(baseMetatable.__newindex) == "function" then 63 | staticMetatable.__newindex = baseMetatable.__newindex 64 | end 65 | end 66 | setmetatable(target.prototype, base.prototype) 67 | if type(base.prototype.__index) == "function" then 68 | target.prototype.__index = base.prototype.__index 69 | end 70 | if type(base.prototype.__newindex) == "function" then 71 | target.prototype.__newindex = base.prototype.__newindex 72 | end 73 | if type(base.prototype.__tostring) == "function" then 74 | target.prototype.__tostring = base.prototype.__tostring 75 | end 76 | end 77 | 78 | local Error, RangeError, ReferenceError, SyntaxError, TypeError, URIError 79 | do 80 | local function getErrorStack(self, constructor) 81 | local level = 1 82 | while true do 83 | local info = debug.getinfo(level, "f") 84 | level = level + 1 85 | if not info then 86 | level = 1 87 | break 88 | elseif info.func == constructor then 89 | break 90 | end 91 | end 92 | if __TS__StringIncludes(_VERSION, "Lua 5.0") then 93 | return debug.traceback(("[Level " .. tostring(level)) .. "]") 94 | else 95 | return debug.traceback(nil, level) 96 | end 97 | end 98 | local function wrapErrorToString(self, getDescription) 99 | return function(self) 100 | local description = getDescription(self) 101 | local caller = debug.getinfo(3, "f") 102 | local isClassicLua = __TS__StringIncludes(_VERSION, "Lua 5.0") or _VERSION == "Lua 5.1" 103 | if isClassicLua or caller and caller.func ~= error then 104 | return description 105 | else 106 | return (description .. "\n") .. tostring(self.stack) 107 | end 108 | end 109 | end 110 | local function initErrorClass(self, Type, name) 111 | Type.name = name 112 | return setmetatable( 113 | Type, 114 | {__call = function(____, _self, message) return __TS__New(Type, message) end} 115 | ) 116 | end 117 | local ____initErrorClass_1 = initErrorClass 118 | local ____class_0 = __TS__Class() 119 | ____class_0.name = "" 120 | function ____class_0.prototype.____constructor(self, message) 121 | if message == nil then 122 | message = "" 123 | end 124 | self.message = message 125 | self.name = "Error" 126 | self.stack = getErrorStack(nil, self.constructor.new) 127 | local metatable = getmetatable(self) 128 | if metatable and not metatable.__errorToStringPatched then 129 | metatable.__errorToStringPatched = true 130 | metatable.__tostring = wrapErrorToString(nil, metatable.__tostring) 131 | end 132 | end 133 | function ____class_0.prototype.__tostring(self) 134 | return self.message ~= "" and (self.name .. ": ") .. self.message or self.name 135 | end 136 | Error = ____initErrorClass_1(nil, ____class_0, "Error") 137 | local function createErrorClass(self, name) 138 | local ____initErrorClass_3 = initErrorClass 139 | local ____class_2 = __TS__Class() 140 | ____class_2.name = ____class_2.name 141 | __TS__ClassExtends(____class_2, Error) 142 | function ____class_2.prototype.____constructor(self, ...) 143 | ____class_2.____super.prototype.____constructor(self, ...) 144 | self.name = name 145 | end 146 | return ____initErrorClass_3(nil, ____class_2, name) 147 | end 148 | RangeError = createErrorClass(nil, "RangeError") 149 | ReferenceError = createErrorClass(nil, "ReferenceError") 150 | SyntaxError = createErrorClass(nil, "SyntaxError") 151 | TypeError = createErrorClass(nil, "TypeError") 152 | URIError = createErrorClass(nil, "URIError") 153 | end 154 | -- End of Lua Library inline imports 155 | local ____exports = {} 156 | local shouldRefreshData 157 | local lib = require("neotest.lib") 158 | local ____adapter_2Ddata = require('neotest-playwright.adapter-data') 159 | local data = ____adapter_2Ddata.data 160 | local ____adapter_2Doptions = require('neotest-playwright.adapter-options') 161 | local options = ____adapter_2Doptions.options 162 | local ____logging = require('neotest-playwright.logging') 163 | local logger = ____logging.logger 164 | local ____playwright = require('neotest-playwright.playwright') 165 | local get_config = ____playwright.get_config 166 | local ____position = require('neotest-playwright.position') 167 | local buildTestPosition = ____position.buildTestPosition 168 | local ____project = require('neotest-playwright.project') 169 | local loadPreselectedProjects = ____project.loadPreselectedProjects 170 | local ____report = require('neotest-playwright.report') 171 | local flattenSpecs = ____report.flattenSpecs 172 | ____exports.root = lib.files.match_root_pattern("playwright.config.ts", "playwright.config.js") 173 | ____exports.filterDir = function(name, _rel_path, _root) 174 | return name ~= "node_modules" 175 | end 176 | ____exports.isTestFile = function(file_path) 177 | if not file_path then 178 | return false 179 | end 180 | local endings = { 181 | ".spec.ts", 182 | ".spec.tsx", 183 | ".test.ts", 184 | ".test.tsx", 185 | ".spec.js", 186 | ".spec.jsx", 187 | ".test.js", 188 | ".test.jsx" 189 | } 190 | local result = __TS__ArraySome( 191 | endings, 192 | function(____, ending) return __TS__StringEndsWith(file_path, ending) end 193 | ) 194 | return result 195 | end 196 | ____exports.discoverPositions = function(path) 197 | if lib.subprocess.enabled() then 198 | lib.subprocess.call("require('neotest-playwright.discover').populate_data") 199 | else 200 | ____exports.populate_data() 201 | end 202 | local query = "\n\t\t; -- Namespaces --\n\n\t\t; Matches: test.describe('title')\n\n\t\t(call_expression\n\t\t function: (member_expression) @func_name (#eq? @func_name \"test.describe\")\n\n\t\t arguments: (arguments\n\t\t\t (string (string_fragment) @namespace.name)\n\t\t\t ) @namespace.definition\n\t\t )\n\n\t\t; -- Tests --\n\n\t\t; Matches: test('title')\n\n\t\t(call_expression\n\t\t function: (identifier) @func_name (#eq? @func_name \"test\")\n\n\t\t arguments: (arguments\n\t\t\t(string (string_fragment) @test.name\n\t\t\t)\n\t\t\t) @test.definition\n\t\t)\n\n\t\t; Matches: test.only('title') / test.fixme('title')\n\n\t\t(call_expression\n\t\t function: (member_expression) @func_name (#any-of? @func_name \"test.only\" \"test.fixme\" \"test.skip\")\n\n\t\t arguments: (arguments\n\t\t\t(string (string_fragment) @test.name)\n\t\t\t) @test.definition\n\t\t)\n\t\t" 203 | return lib.treesitter.parse_positions( 204 | path, 205 | query, 206 | __TS__ObjectAssign({nested_tests = true, position_id = "require(\"neotest-playwright.discover\")._position_id"}, options.enable_dynamic_test_discovery and ({build_position = "require(\"neotest-playwright.discover\")._build_position"}) or ({})) 207 | ) 208 | end 209 | local function getMatchType(node) 210 | if node["test.name"] ~= nil then 211 | return "test" 212 | elseif node["namespace.name"] ~= nil then 213 | return "namespace" 214 | else 215 | error( 216 | __TS__New(Error, "Unknown match type"), 217 | 0 218 | ) 219 | end 220 | end 221 | ____exports._build_position = function(filePath, source, capturedNodes) 222 | local match_type = getMatchType(capturedNodes) 223 | local name = vim.treesitter.get_node_text(capturedNodes[match_type .. ".name"], source) 224 | local definition = capturedNodes[match_type .. ".definition"] 225 | local range = {definition:range()} 226 | if match_type == "namespace" then 227 | return {type = match_type, range = range, path = filePath, name = name} 228 | elseif match_type == "test" then 229 | local base = {type = match_type, range = range, path = filePath, name = name} 230 | local position = buildTestPosition(base) 231 | return position 232 | else 233 | error( 234 | __TS__New(Error, "Unknown match type"), 235 | 0 236 | ) 237 | end 238 | end 239 | ____exports._position_id = function(position, _parent) 240 | if position.id then 241 | return position.id 242 | else 243 | return (position.path .. "::") .. position.name 244 | end 245 | end 246 | ____exports.populate_data = function() 247 | if shouldRefreshData() then 248 | ____exports.refresh_data() 249 | end 250 | end 251 | --- Called by the subprocess before parsing a file 252 | ____exports.refresh_data = function() 253 | logger("debug", "Refreshing data") 254 | local report = get_config() 255 | data.report = report 256 | data.specs = flattenSpecs(report.suites) 257 | data.rootDir = report.config.rootDir 258 | options.projects = loadPreselectedProjects() or ({}) 259 | end 260 | shouldRefreshData = function() 261 | if data.specs and data.rootDir then 262 | logger("debug", "Data already exists. Skipping refresh.") 263 | return false 264 | else 265 | return true 266 | end 267 | end 268 | return ____exports 269 | -------------------------------------------------------------------------------- /lua/neotest-playwright/report.lua: -------------------------------------------------------------------------------- 1 | --[[ Generated with https://github.com/TypeScriptToLua/TypeScriptToLua ]] 2 | -- Lua Library inline imports 3 | local function __TS__StringIncludes(self, searchString, position) 4 | if not position then 5 | position = 1 6 | else 7 | position = position + 1 8 | end 9 | local index = string.find(self, searchString, position, true) 10 | return index ~= nil 11 | end 12 | 13 | local function __TS__New(target, ...) 14 | local instance = setmetatable({}, target.prototype) 15 | instance:____constructor(...) 16 | return instance 17 | end 18 | 19 | local function __TS__Class(self) 20 | local c = {prototype = {}} 21 | c.prototype.__index = c.prototype 22 | c.prototype.constructor = c 23 | return c 24 | end 25 | 26 | local function __TS__ClassExtends(target, base) 27 | target.____super = base 28 | local staticMetatable = setmetatable({__index = base}, base) 29 | setmetatable(target, staticMetatable) 30 | local baseMetatable = getmetatable(base) 31 | if baseMetatable then 32 | if type(baseMetatable.__index) == "function" then 33 | staticMetatable.__index = baseMetatable.__index 34 | end 35 | if type(baseMetatable.__newindex) == "function" then 36 | staticMetatable.__newindex = baseMetatable.__newindex 37 | end 38 | end 39 | setmetatable(target.prototype, base.prototype) 40 | if type(base.prototype.__index) == "function" then 41 | target.prototype.__index = base.prototype.__index 42 | end 43 | if type(base.prototype.__newindex) == "function" then 44 | target.prototype.__newindex = base.prototype.__newindex 45 | end 46 | if type(base.prototype.__tostring) == "function" then 47 | target.prototype.__tostring = base.prototype.__tostring 48 | end 49 | end 50 | 51 | local Error, RangeError, ReferenceError, SyntaxError, TypeError, URIError 52 | do 53 | local function getErrorStack(self, constructor) 54 | local level = 1 55 | while true do 56 | local info = debug.getinfo(level, "f") 57 | level = level + 1 58 | if not info then 59 | level = 1 60 | break 61 | elseif info.func == constructor then 62 | break 63 | end 64 | end 65 | if __TS__StringIncludes(_VERSION, "Lua 5.0") then 66 | return debug.traceback(("[Level " .. tostring(level)) .. "]") 67 | else 68 | return debug.traceback(nil, level) 69 | end 70 | end 71 | local function wrapErrorToString(self, getDescription) 72 | return function(self) 73 | local description = getDescription(self) 74 | local caller = debug.getinfo(3, "f") 75 | local isClassicLua = __TS__StringIncludes(_VERSION, "Lua 5.0") or _VERSION == "Lua 5.1" 76 | if isClassicLua or caller and caller.func ~= error then 77 | return description 78 | else 79 | return (description .. "\n") .. tostring(self.stack) 80 | end 81 | end 82 | end 83 | local function initErrorClass(self, Type, name) 84 | Type.name = name 85 | return setmetatable( 86 | Type, 87 | {__call = function(____, _self, message) return __TS__New(Type, message) end} 88 | ) 89 | end 90 | local ____initErrorClass_1 = initErrorClass 91 | local ____class_0 = __TS__Class() 92 | ____class_0.name = "" 93 | function ____class_0.prototype.____constructor(self, message) 94 | if message == nil then 95 | message = "" 96 | end 97 | self.message = message 98 | self.name = "Error" 99 | self.stack = getErrorStack(nil, self.constructor.new) 100 | local metatable = getmetatable(self) 101 | if metatable and not metatable.__errorToStringPatched then 102 | metatable.__errorToStringPatched = true 103 | metatable.__tostring = wrapErrorToString(nil, metatable.__tostring) 104 | end 105 | end 106 | function ____class_0.prototype.__tostring(self) 107 | return self.message ~= "" and (self.name .. ": ") .. self.message or self.name 108 | end 109 | Error = ____initErrorClass_1(nil, ____class_0, "Error") 110 | local function createErrorClass(self, name) 111 | local ____initErrorClass_3 = initErrorClass 112 | local ____class_2 = __TS__Class() 113 | ____class_2.name = ____class_2.name 114 | __TS__ClassExtends(____class_2, Error) 115 | function ____class_2.prototype.____constructor(self, ...) 116 | ____class_2.____super.prototype.____constructor(self, ...) 117 | self.name = name 118 | end 119 | return ____initErrorClass_3(nil, ____class_2, name) 120 | end 121 | RangeError = createErrorClass(nil, "RangeError") 122 | ReferenceError = createErrorClass(nil, "ReferenceError") 123 | SyntaxError = createErrorClass(nil, "SyntaxError") 124 | TypeError = createErrorClass(nil, "TypeError") 125 | URIError = createErrorClass(nil, "URIError") 126 | end 127 | 128 | local __TS__Symbol, Symbol 129 | do 130 | local symbolMetatable = {__tostring = function(self) 131 | return ("Symbol(" .. (self.description or "")) .. ")" 132 | end} 133 | function __TS__Symbol(description) 134 | return setmetatable({description = description}, symbolMetatable) 135 | end 136 | Symbol = { 137 | iterator = __TS__Symbol("Symbol.iterator"), 138 | hasInstance = __TS__Symbol("Symbol.hasInstance"), 139 | species = __TS__Symbol("Symbol.species"), 140 | toStringTag = __TS__Symbol("Symbol.toStringTag") 141 | } 142 | end 143 | 144 | local __TS__Iterator 145 | do 146 | local function iteratorGeneratorStep(self) 147 | local co = self.____coroutine 148 | local status, value = coroutine.resume(co) 149 | if not status then 150 | error(value, 0) 151 | end 152 | if coroutine.status(co) == "dead" then 153 | return 154 | end 155 | return true, value 156 | end 157 | local function iteratorIteratorStep(self) 158 | local result = self:next() 159 | if result.done then 160 | return 161 | end 162 | return true, result.value 163 | end 164 | local function iteratorStringStep(self, index) 165 | index = index + 1 166 | if index > #self then 167 | return 168 | end 169 | return index, string.sub(self, index, index) 170 | end 171 | function __TS__Iterator(iterable) 172 | if type(iterable) == "string" then 173 | return iteratorStringStep, iterable, 0 174 | elseif iterable.____coroutine ~= nil then 175 | return iteratorGeneratorStep, iterable 176 | elseif iterable[Symbol.iterator] then 177 | local iterator = iterable[Symbol.iterator](iterable) 178 | return iteratorIteratorStep, iterator 179 | else 180 | return ipairs(iterable) 181 | end 182 | end 183 | end 184 | 185 | local Map 186 | do 187 | Map = __TS__Class() 188 | Map.name = "Map" 189 | function Map.prototype.____constructor(self, entries) 190 | self[Symbol.toStringTag] = "Map" 191 | self.items = {} 192 | self.size = 0 193 | self.nextKey = {} 194 | self.previousKey = {} 195 | if entries == nil then 196 | return 197 | end 198 | local iterable = entries 199 | if iterable[Symbol.iterator] then 200 | local iterator = iterable[Symbol.iterator](iterable) 201 | while true do 202 | local result = iterator:next() 203 | if result.done then 204 | break 205 | end 206 | local value = result.value 207 | self:set(value[1], value[2]) 208 | end 209 | else 210 | local array = entries 211 | for ____, kvp in ipairs(array) do 212 | self:set(kvp[1], kvp[2]) 213 | end 214 | end 215 | end 216 | function Map.prototype.clear(self) 217 | self.items = {} 218 | self.nextKey = {} 219 | self.previousKey = {} 220 | self.firstKey = nil 221 | self.lastKey = nil 222 | self.size = 0 223 | end 224 | function Map.prototype.delete(self, key) 225 | local contains = self:has(key) 226 | if contains then 227 | self.size = self.size - 1 228 | local next = self.nextKey[key] 229 | local previous = self.previousKey[key] 230 | if next ~= nil and previous ~= nil then 231 | self.nextKey[previous] = next 232 | self.previousKey[next] = previous 233 | elseif next ~= nil then 234 | self.firstKey = next 235 | self.previousKey[next] = nil 236 | elseif previous ~= nil then 237 | self.lastKey = previous 238 | self.nextKey[previous] = nil 239 | else 240 | self.firstKey = nil 241 | self.lastKey = nil 242 | end 243 | self.nextKey[key] = nil 244 | self.previousKey[key] = nil 245 | end 246 | self.items[key] = nil 247 | return contains 248 | end 249 | function Map.prototype.forEach(self, callback) 250 | for ____, key in __TS__Iterator(self:keys()) do 251 | callback(nil, self.items[key], key, self) 252 | end 253 | end 254 | function Map.prototype.get(self, key) 255 | return self.items[key] 256 | end 257 | function Map.prototype.has(self, key) 258 | return self.nextKey[key] ~= nil or self.lastKey == key 259 | end 260 | function Map.prototype.set(self, key, value) 261 | local isNewValue = not self:has(key) 262 | if isNewValue then 263 | self.size = self.size + 1 264 | end 265 | self.items[key] = value 266 | if self.firstKey == nil then 267 | self.firstKey = key 268 | self.lastKey = key 269 | elseif isNewValue then 270 | self.nextKey[self.lastKey] = key 271 | self.previousKey[key] = self.lastKey 272 | self.lastKey = key 273 | end 274 | return self 275 | end 276 | Map.prototype[Symbol.iterator] = function(self) 277 | return self:entries() 278 | end 279 | function Map.prototype.entries(self) 280 | local items = self.items 281 | local nextKey = self.nextKey 282 | local key = self.firstKey 283 | return { 284 | [Symbol.iterator] = function(self) 285 | return self 286 | end, 287 | next = function(self) 288 | local result = {done = not key, value = {key, items[key]}} 289 | key = nextKey[key] 290 | return result 291 | end 292 | } 293 | end 294 | function Map.prototype.keys(self) 295 | local nextKey = self.nextKey 296 | local key = self.firstKey 297 | return { 298 | [Symbol.iterator] = function(self) 299 | return self 300 | end, 301 | next = function(self) 302 | local result = {done = not key, value = key} 303 | key = nextKey[key] 304 | return result 305 | end 306 | } 307 | end 308 | function Map.prototype.values(self) 309 | local items = self.items 310 | local nextKey = self.nextKey 311 | local key = self.firstKey 312 | return { 313 | [Symbol.iterator] = function(self) 314 | return self 315 | end, 316 | next = function(self) 317 | local result = {done = not key, value = items[key]} 318 | key = nextKey[key] 319 | return result 320 | end 321 | } 322 | end 323 | Map[Symbol.species] = Map 324 | end 325 | 326 | local function __TS__ObjectEntries(obj) 327 | local result = {} 328 | local len = 0 329 | for key in pairs(obj) do 330 | len = len + 1 331 | result[len] = {key, obj[key]} 332 | end 333 | return result 334 | end 335 | 336 | local function __TS__ObjectFromEntries(entries) 337 | local obj = {} 338 | local iterable = entries 339 | if iterable[Symbol.iterator] then 340 | local iterator = iterable[Symbol.iterator](iterable) 341 | while true do 342 | local result = iterator:next() 343 | if result.done then 344 | break 345 | end 346 | local value = result.value 347 | obj[value[1]] = value[2] 348 | end 349 | else 350 | for ____, entry in ipairs(entries) do 351 | obj[entry[1]] = entry[2] 352 | end 353 | end 354 | return obj 355 | end 356 | 357 | local function __TS__ObjectAssign(target, ...) 358 | local sources = {...} 359 | for i = 1, #sources do 360 | local source = sources[i] 361 | for key in pairs(source) do 362 | target[key] = source[key] 363 | end 364 | end 365 | return target 366 | end 367 | 368 | local function __TS__ArrayMap(self, callbackfn, thisArg) 369 | local result = {} 370 | for i = 1, #self do 371 | result[i] = callbackfn(thisArg, self[i], i - 1, self) 372 | end 373 | return result 374 | end 375 | 376 | local function __TS__ArrayIsArray(value) 377 | return type(value) == "table" and (value[1] ~= nil or next(value) == nil) 378 | end 379 | 380 | local function __TS__ArrayConcat(self, ...) 381 | local items = {...} 382 | local result = {} 383 | local len = 0 384 | for i = 1, #self do 385 | len = len + 1 386 | result[len] = self[i] 387 | end 388 | for i = 1, #items do 389 | local item = items[i] 390 | if __TS__ArrayIsArray(item) then 391 | for j = 1, #item do 392 | len = len + 1 393 | result[len] = item[j] 394 | end 395 | else 396 | len = len + 1 397 | result[len] = item 398 | end 399 | end 400 | return result 401 | end 402 | 403 | local function __TS__ArrayPushArray(self, items) 404 | local len = #self 405 | for i = 1, #items do 406 | len = len + 1 407 | self[len] = items[i] 408 | end 409 | return len 410 | end 411 | -- End of Lua Library inline imports 412 | local ____exports = {} 413 | local getSpecStatus, constructSpecKey, collectSpecErrors, toNeotestError 414 | local ____neotest_2Dplaywright_2Eutil = require("neotest-playwright.util") 415 | local cleanAnsi = ____neotest_2Dplaywright_2Eutil.cleanAnsi 416 | local ____adapter_2Doptions = require('neotest-playwright.adapter-options') 417 | local options = ____adapter_2Doptions.options 418 | local ____helpers = require('neotest-playwright.helpers') 419 | local emitError = ____helpers.emitError 420 | ____exports.decodeOutput = function(data) 421 | local ok, parsed = pcall(vim.json.decode, data, {luanil = {object = true}}) 422 | if not ok then 423 | emitError("Failed to parse test output json") 424 | error( 425 | __TS__New(Error, "Failed to parse test output json"), 426 | 0 427 | ) 428 | end 429 | return parsed 430 | end 431 | ____exports.parseOutput = function(report) 432 | if #report.errors > 1 then 433 | emitError("Global errors found in report") 434 | end 435 | local all_results = __TS__New(Map) 436 | for ____, suite in ipairs(report.suites) do 437 | local results = ____exports.parseSuite(suite, report) 438 | for ____, ____value in ipairs(__TS__ObjectEntries(results)) do 439 | local key = ____value[1] 440 | local result = ____value[2] 441 | all_results:set(key, result) 442 | end 443 | end 444 | if all_results.size == 0 then 445 | emitError("No test suites found in report") 446 | end 447 | return __TS__ObjectFromEntries(all_results) 448 | end 449 | ____exports.parseSuite = function(suite, report) 450 | local results = {} 451 | local specs = ____exports.flattenSpecs({suite}) 452 | for ____, spec in ipairs(specs) do 453 | local key 454 | if options.enable_dynamic_test_discovery then 455 | key = spec.id 456 | else 457 | key = constructSpecKey(report, spec) 458 | end 459 | results[key] = ____exports.parseSpec(spec) 460 | end 461 | return results 462 | end 463 | ____exports.flattenSpecs = function(suites) 464 | local specs = {} 465 | for ____, suite in ipairs(suites) do 466 | local suiteSpecs = __TS__ArrayMap( 467 | suite.specs, 468 | function(____, spec) return __TS__ObjectAssign({}, spec, {suiteTitle = suite.title}) end 469 | ) 470 | specs = __TS__ArrayConcat( 471 | specs, 472 | suiteSpecs, 473 | ____exports.flattenSpecs(suite.suites or ({})) 474 | ) 475 | end 476 | return specs 477 | end 478 | ____exports.parseSpec = function(spec) 479 | local status = getSpecStatus(spec) 480 | local errors = __TS__ArrayMap( 481 | collectSpecErrors(spec), 482 | function(____, s) return toNeotestError(s) end 483 | ) 484 | local ____opt_2 = spec.tests[1] 485 | local ____opt_0 = ____opt_2 and ____opt_2.results[1] 486 | local attachments = ____opt_0 and ____opt_0.attachments or ({}) 487 | local data = {status = status, short = (spec.title .. ": ") .. status, errors = errors, attachments = attachments} 488 | return data 489 | end 490 | getSpecStatus = function(spec) 491 | if not spec.ok then 492 | return "failed" 493 | else 494 | local ____opt_4 = spec.tests[1] 495 | if (____opt_4 and ____opt_4.status) == "skipped" then 496 | return "skipped" 497 | else 498 | return "passed" 499 | end 500 | end 501 | end 502 | constructSpecKey = function(report, spec) 503 | local dir = report.config.rootDir 504 | local file = spec.file 505 | local name = spec.title 506 | local key = (((dir .. "/") .. file) .. "::") .. name 507 | return key 508 | end 509 | --- Collect all errors from a spec by traversing spec -> tests[] -> results[]. 510 | -- Return a single flat array containing any errors. 511 | collectSpecErrors = function(spec) 512 | local errors = {} 513 | for ____, test in ipairs(spec.tests) do 514 | for ____, result in ipairs(test.results) do 515 | __TS__ArrayPushArray(errors, result.errors) 516 | end 517 | end 518 | return errors 519 | end 520 | --- Convert Playwright error to neotest error 521 | toNeotestError = function(____error) 522 | local ____opt_6 = ____error.location 523 | local line = ____opt_6 and ____opt_6.line 524 | return { 525 | message = cleanAnsi(____error.message), 526 | line = line and line - 1 or 0 527 | } 528 | end 529 | return ____exports 530 | --------------------------------------------------------------------------------