diff --git a/.github/branch-protection.json b/.github/branch-protection.json new file mode 100644 index 0000000..da18229 --- /dev/null +++ b/.github/branch-protection.json @@ -0,0 +1,25 @@ +{ + "required_status_checks": { + "strict": true, + "contexts": [ + "py3.10 on ubuntu-latest", + "py3.11 on ubuntu-latest", + "py3.12 on ubuntu-latest", + "py3.10 on windows-latest", + "py3.11 on windows-latest", + "py3.12 on windows-latest", + "py3.10 on macos-latest", + "py3.11 on macos-latest", + "py3.12 on macos-latest", + "coverage gate (85%)", + "black and flake8", + "frontend modules", + "viewerjs" + ] + }, + "enforce_admins": true, + "required_pull_request_reviews": null, + "restrictions": null, + "allow_force_pushes": false, + "allow_deletions": false +} diff --git a/.github/workflows/viewerjs-check.yml b/.github/workflows/viewerjs-check.yml index 46d7a28..f6f05f3 100644 --- a/.github/workflows/viewerjs-check.yml +++ b/.github/workflows/viewerjs-check.yml @@ -2,22 +2,9 @@ name: Viewer.js vendor and compatibility on: pull_request: - paths: - - 'tools/vendor/**' - - 'tests/vendor/**' - - 'tests/js/**' - - 'web/js/**' - - 'web/lib/**' - - '.github/workflows/viewerjs-*.yml' + branches: [main] push: branches: [main] - paths: - - 'tools/vendor/**' - - 'tests/vendor/**' - - 'tests/js/**' - - 'web/js/**' - - 'web/lib/**' - - '.github/workflows/viewerjs-*.yml' workflow_dispatch: permissions: @@ -26,8 +13,15 @@ permissions: jobs: viewerjs: runs-on: ubuntu-latest + timeout-minutes: 15 steps: - uses: actions/checkout@v7 + - uses: actions/setup-python@v7 + with: + python-version: '3.12' + cache: pip + cache-dependency-path: requirements-dev.txt + - run: python -m pip install -r requirements-dev.txt - uses: actions/setup-node@v7 with: node-version: '24' @@ -36,6 +30,18 @@ jobs: - run: npm ci --prefix tools/vendor --ignore-scripts - run: npm run --prefix tools/vendor check - run: npm run --prefix tools/vendor test - - run: node --test tests/js/*.test.js - - run: npx --prefix tools/vendor playwright install --with-deps chromium - - run: npm run --prefix tools/vendor test:browser + - run: npx --prefix tools/vendor playwright install --with-deps chromium firefox webkit + - name: Frontend unit, viewer, workflow, and coverage checks + run: npm run --prefix tools/vendor test:coverage + - name: Firefox and WebKit page workflows + run: npm run --prefix tools/vendor test:workflows -- --project firefox --project webkit + - name: Upload browser traces and frontend coverage + if: always() + uses: actions/upload-artifact@v7 + with: + name: frontend-test-reports + path: | + tools/vendor/test-results/ + coverage/js/ + !coverage/js/raw/** + retention-days: 14 diff --git a/.gitignore b/.gitignore index c29d97c..3a9145d 100644 --- a/.gitignore +++ b/.gitignore @@ -205,3 +205,6 @@ cython_debug/ node_modules/ test-results/ playwright-report/ + +# Combined frontend coverage reports +coverage/ diff --git a/TESTING.md b/TESTING.md new file mode 100644 index 0000000..a286611 --- /dev/null +++ b/TESTING.md @@ -0,0 +1,83 @@ +# Testing and merge confidence + +Routine changes should merge after the required checks pass and the diff is +reviewed. Add a regression test for each bug; prove it fails before the fix. + +## What runs + +| Layer | Real components | Replaced boundaries | +| --- | --- | --- | +| Python tests | Domain logic, SQLite, API handlers, filesystem checks | ComfyUI host and ML runtimes where needed | +| JavaScript tests | Frontend modules and controller methods | DOM/network fixtures for focused edge cases | +| Viewer checks | Production Viewer.js and frontend methods in Chromium desktop/touch | Controlled images and page fixtures | +| Page workflows | Production HTML, startup, click handlers, HTTP routes, SQLite, PNG files, thumbnails | ComfyUI host services and the tagging model | + +Page workflows cover save/edit/reload, duplicate-edit rejection, gallery image +identity, Tags selection and viewer cleanup, delayed responses, auto-tag review +and persistence, and infinite scrolling without replacing existing cards. +They run in Chromium, Firefox, WebKit, and Chromium touch emulation. No GPU, +model downloads, running ComfyUI instance, or personal configuration is required. +The server binds to localhost and creates disposable directories and a database; +its reset endpoint exists only in the test harness. + +## Run locally + +Use a Python environment containing `requirements-dev.txt` and Node.js 24: + +```sh +python -m unittest discover -s tests -q +npm ci --prefix tools/vendor --ignore-scripts +npx --prefix tools/vendor playwright install --with-deps chromium firefox webkit +npm run --prefix tools/vendor test +npm run --prefix tools/vendor test:coverage +npm run --prefix tools/vendor test:workflows -- --project firefox --project webkit +``` + +`PM_TEST_PYTHON` selects the Python executable; `PM_TEST_PORT` selects the local +port (default 8765). Set these environment variables when Python is outside PATH +or the port is occupied. Test servers are never reused. On unsupported Linux +distributions, browser system dependencies may need manual installation; CI uses +Ubuntu. For a quick browser run, use `test:workflows -- --project chromium`. + +## Coverage and failures + +Python CI enforces 85% combined statement/branch coverage. Frontend coverage merges +Node V8 unit coverage with Chromium page-workflow coverage through c8. Every +`web/js/*.js` file appears, including untouched files at zero coverage. The +controller tests provide V8 filenames so their executed code is counted too. + +`tools/vendor/frontend-coverage-baseline.json` holds a floor for every file and +metric. CI rejects regressions, missing measurements, and new files without a +reviewed baseline. These floors reflect current coverage, with two percentage +points of allowance for runtime/timing differences; they do not claim 80% coverage +of the large controllers. New files need at least 80% line coverage when added via +the baseline updater. Raise floors with `npm run --prefix tools/vendor +coverage:update`; it reruns the suites and cannot lower an existing floor. +Review baseline changes like code changes. + +The `viewerjs` check runs on every PR, including backend-only changes. GitHub +artifacts named `frontend-test-reports` contain HTML coverage, a JSON summary, +and failure screenshots/traces. Open traces with Playwright's `show-trace` tool. + +Live checks remain useful for ComfyUI host compatibility, real inference/GPU or +model dependency changes, and visual design changes. The automated fixtures do +not certify those environments or every existing product feature. + +## Required merge checks + +`.github/branch-protection.json` records the required checks: all nine Python/OS +combinations, Python coverage, formatting, frontend modules, and `viewerjs`. +The branch must be current with main, and administrators follow the same checks. +It does not require an additional human approval. + +A repository administrator applies the configuration after the new workflow is +passing, then verifies it: + +```sh +gh api --method PUT repos/ComfyAssets/ComfyUI_PromptManager/branches/main/protection \ + --input .github/branch-protection.json +gh api repos/ComfyAssets/ComfyUI_PromptManager/branches/main/protection/required_status_checks +``` + +Update the manifest whenever required job names change. Keep the frontend check +free of path filters so a required check is never silently skipped. diff --git a/tests/browser/__init__.py b/tests/browser/__init__.py new file mode 100644 index 0000000..3bc0d06 --- /dev/null +++ b/tests/browser/__init__.py @@ -0,0 +1 @@ +"""Browser workflow fixtures; never imported by production code.""" diff --git a/tests/browser/server.py b/tests/browser/server.py new file mode 100644 index 0000000..f5f011b --- /dev/null +++ b/tests/browser/server.py @@ -0,0 +1,154 @@ +"""Serve production pages and API routes with disposable state for browser tests.""" + +import argparse +import json +import os +import shutil +from pathlib import Path +import sys +import tempfile +import time +import types + +from aiohttp import web +from PIL import Image +from PIL.PngImagePlugin import PngInfo + + +class TestModel: + default_prompt = "Describe the image" + wd14_general_threshold = 0.35 + wd14_character_threshold = 0.85 + custom_prompt = "" + loaded = None + + def get_models_status(self) -> dict: + return { + name: {"downloaded": True, "loaded": self.loaded == name} + for name in ("gguf", "wd14-vit", "wd14-convnext", "wd14-swinv2") + } + + def is_model_loaded(self) -> bool: + return self.loaded is not None + + def get_loaded_model_type(self) -> str | None: + return self.loaded + + def load_model(self, model_type: str, use_gpu: bool = True) -> bool: + self.loaded = model_type + return True + + def unload_model(self) -> None: + self.loaded = None + + def generate_tags(self, image_path: str, **kwargs: object) -> list[str]: + return ["generated", Path(image_path).parent.parent.name] + + +def create_app(root: Path) -> web.Application: + os.environ["PROMPT_MANAGER_CONFIG_PATH"] = str(root / "config.json") + os.chdir(root) + app = web.Application() + routes = web.RouteTableDef() + server = types.ModuleType("server") + server.PromptServer = types.SimpleNamespace( + instance=types.SimpleNamespace(app=app, routes=routes) + ) + sys.modules["server"] = server + folder_paths = types.ModuleType("folder_paths") + folder_paths.base_path = str(root) + folder_paths.models_dir = str(root / "models") + for name in ("output", "input", "temp", "user"): + directory = root / name + directory.mkdir() + setattr(folder_paths, f"get_{name}_directory", lambda path=directory: str(path)) + sys.modules["folder_paths"] = folder_paths + + from py.config import GalleryConfig, PromptManagerConfig + + PromptManagerConfig.DEFAULT_DB_PATH = str(root / "browser.db") + GalleryConfig.MONITORING_DIRECTORIES = [str(root / "output"), str(root / "input")] + + from py.api import PromptManagerAPI + from py import autotag + from utils.hashing import generate_prompt_hash + + model = TestModel() + autotag.get_autotag_service = lambda: model + api = PromptManagerAPI() + api.add_routes(routes) + + def seed(extra_images: int = 0) -> None: + with api.db.model.get_connection() as conn: + for table in ("generated_images", "prompt_tags", "tags", "prompts"): + conn.execute(f"DELETE FROM {table}") + for name in ("output", "input"): + shutil.rmtree(root / name) + (root / name).mkdir() + for index, (directory, text, tag) in enumerate( + [("output", "Cat portrait", "cats"), ("input", "Dog landscape", "dogs")] + ): + image_path = root / directory / "shared" / "same.png" + image_path.parent.mkdir(exist_ok=True) + info = PngInfo() + info.add_text( + "prompt", + json.dumps( + {"1": {"class_type": "CLIPTextEncode", "inputs": {"text": text}}} + ), + ) + with Image.new("RGB", (64, 64), (index * 255, 64, 128)) as image: + image.save(image_path, pnginfo=info) + stamp = time.time() - index * 10 + os.utime(image_path, (stamp, stamp)) + pid = api.db.save_prompt( + text, tags=[tag], prompt_hash=generate_prompt_hash(text) + ) + api.db.link_image_to_prompt(pid, str(image_path)) + if index == 0: + for number in range(extra_images): + extra = root / directory / "bulk" / f"{number:04}.png" + extra.parent.mkdir(exist_ok=True) + shutil.copyfile(image_path, extra) + api.db.link_image_to_prompt(pid, str(extra)) + api._generate_gallery_thumbnails_sync(api._get_all_output_dirs(), (150, 150)) + api.invalidate_gallery_cache() + + @routes.post("/__test__/reset") + async def reset(request: web.Request) -> web.Response: + data = await request.json() if request.can_read_body else {} + extra = data.get("extra_images", 0) + if ( + isinstance(extra, bool) + or not isinstance(extra, int) + or not 0 <= extra <= 200 + ): + raise web.HTTPBadRequest() + await api._run_in_executor(seed, extra) + return web.json_response({"success": True}) + + @routes.get("/api/settings/Comfy.ColorPalette") + async def palette(request: web.Request) -> web.Response: + return web.json_response("dark") + + async def cleanup(application: web.Application) -> None: + api.db.close_all() + + seed() + app.on_cleanup.append(cleanup) + app.add_routes(routes) + return app + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--port", type=int, default=8765) + args = parser.parse_args() + with tempfile.TemporaryDirectory(prefix="pm-browser-") as directory: + web.run_app( + create_app(Path(directory).resolve()), host="127.0.0.1", port=args.port + ) + + +if __name__ == "__main__": + main() diff --git a/tests/browser/workflows.spec.mjs b/tests/browser/workflows.spec.mjs new file mode 100644 index 0000000..0fbfcff --- /dev/null +++ b/tests/browser/workflows.spec.mjs @@ -0,0 +1,141 @@ +import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs'; +import { test, expect } from '../../tools/vendor/node_modules/@playwright/test/index.mjs'; + +test.beforeEach(async ({ request, page, browserName }) => { + await startCoverage(page, browserName); + await expect(await request.post('/__test__/reset')).toBeOK(); + page.workflowErrors = []; + page.on('pageerror', error => page.workflowErrors.push(error.message)); + page.on('response', response => { + if (response.status() >= 500 && response.url().includes('/prompt_manager/')) { + page.workflowErrors.push(`${response.status()} ${response.url()}`); + } + }); + await page.addInitScript(() => { localStorage.setItem('pm_update_notice_dismissed', '3.2.0'); }); +}); + +test.afterEach(async ({ page, browserName }) => { + await saveCoverage(page, browserName); + expect(page.workflowErrors).toEqual([]); +}); + +test('actual gallery form saves a prompt that the admin page can edit and reload', async ({ page, request }) => { + await page.goto('/prompt_manager/gallery'); + await page.locator('#addPromptBtn').click(); + await page.locator('#addPromptText').fill('Browser saved landscape'); + await page.locator('#addPromptCategory').fill('Browser tests'); + await page.locator('#addPromptTagInput').fill('browser-tag'); + await page.locator('#addPromptTagInput').press('Enter'); + await page.locator('#saveNewPromptBtn').click(); + await expect(page.locator('#addPromptModal')).toBeHidden(); + await page.goto('/prompt_manager/admin'); + const text = page.locator('.prompt-text').filter({ hasText: 'Browser saved landscape' }); + await expect(text).toBeVisible(); + const id = await text.getAttribute('data-id'); + await page.locator(`[data-action="edit"][data-prompt-id="${id}"]`).click(); + const editor = page.locator(`.prompt-text[data-id="${id}"]`); + await editor.fill('Browser edited portrait'); + await editor.press('Enter'); + await expect(page.locator('.prompt-text').filter({ hasText: 'Browser edited portrait' })).toBeVisible(); + await page.reload(); + await expect(page.locator('.prompt-text').filter({ hasText: 'Browser edited portrait' })).toBeVisible(); + const saved = await request.post('/prompt_manager/save', { data: { text: 'Browser saved landscape' } }); + expect((await saved.json()).prompt_id).not.toBe(Number(id)); +}); + +test('actual gallery review generates and persists tags for the selected root', async ({ page, request }) => { + await page.goto('/prompt_manager/gallery'); + await expect(page.locator('#galleryGrid .image-item')).toHaveCount(2); + await page.locator('#autoTagBtn').click(); + const generated = page.waitForResponse(response => response.url().endsWith('/prompt_manager/autotag/single')); + await page.locator('#startReviewBtn').click(); + const generation = await generated; + expect(generation.request().postDataJSON().root_index).toBe(0); + await expect(page.locator('#reviewTagsContainer')).toContainText('generated'); + const result = page.waitForResponse(response => response.url().endsWith('/prompt_manager/autotag/apply')); + await page.locator('#applyReviewBtn').click(); + expect((await (await result).json()).success).toBe(true); + const data = await (await request.get('/prompt_manager/recent?limit=20')).json(); + expect(data.results.find(prompt => prompt.text === 'Cat portrait').tags).toContain('generated'); + expect(data.results.find(prompt => prompt.text === 'Dog landscape').tags).not.toContain('generated'); +}); + +test('actual Tags page reloads the selected filter and opens its image viewer', async ({ page }) => { + await page.goto('/prompt_manager/admin#/tags'); + await page.locator('[data-tag-name="cats"]').click(); + await expect(page.locator('#tagPromptsGrid')).toContainText('Cat portrait'); + await page.locator('[data-tag-name="dogs"]').click(); + await expect(page.locator('#tagPromptsGrid')).toContainText('Dog landscape'); + await expect(page.locator('#tagPromptsGrid')).not.toContainText('Cat portrait'); + await page.locator('#tagPromptsGrid img').first().click(); + await expect(page.locator('.viewer-canvas img')).toBeVisible(); + await page.locator('.viewer-container').getByRole('button', { name: 'Close', exact: true }).click(); + await expect(page.locator('.viewer-container')).toHaveCount(0); +}); + +test('actual gallery serves colliding filenames and shows the matching prompt', async ({ page }) => { + await page.goto('/prompt_manager/gallery'); + await expect(page.locator('#galleryGrid .image-item')).toHaveCount(2); + const images = page.locator('#galleryGrid img[data-original]'); + const first = await images.nth(0).getAttribute('data-original'); + const second = await images.nth(1).getAttribute('data-original'); + expect(first).not.toBe(second); + await page.locator('#galleryGrid .image-item[data-image-index="0"]').click(); + await expect(page.locator('#metadata-content')).toContainText('Cat portrait'); + await page.keyboard.press('ArrowRight'); + await expect(page.locator('#metadata-content')).toContainText('Dog landscape'); + await page.keyboard.press('Escape'); + await expect(page.locator('#metadata-sidebar')).toHaveCount(0); +}); + +test('an edit collision is rejected by the backend and the page restores the original text', async ({ page }) => { + await page.goto('/prompt_manager/admin'); + const original = page.locator('.prompt-text').filter({ hasText: 'Cat portrait' }); + const id = await original.getAttribute('data-id'); + await page.locator(`[data-action="edit"][data-prompt-id="${id}"]`).click(); + const editor = page.locator(`.prompt-text[data-id="${id}"]`); + await editor.fill('Dog landscape'); + const rejected = page.waitForResponse(response => response.request().method() === 'PUT' && response.url().endsWith(`/prompts/${id}`)); + await editor.press('Enter'); + expect((await rejected).status()).toBe(409); + await expect(editor).toHaveText('Cat portrait'); + await page.reload(); + await expect(page.locator(`.prompt-text[data-id="${id}"]`)).toHaveText('Cat portrait'); +}); + +test('a delayed real Tags response cannot replace a newer selection', async ({ page }) => { + let release; + const delayed = new Promise(resolve => { release = resolve; }); + let received; + const waiting = new Promise(resolve => { received = resolve; }); + await page.route('**/prompt_manager/tags/cats/prompts?*', async route => { + const response = await route.fetch(); + received(); + await delayed; + await route.fulfill({ response }); + }); + await page.goto('/prompt_manager/admin#/tags'); + await page.locator('[data-tag-name="cats"]').click(); + await waiting; + await page.locator('[data-tag-name="dogs"]').click(); + await expect(page.locator('#tagPromptsGrid')).toContainText('Dog landscape'); + const completed = page.waitForResponse(response => response.url().includes('/tags/cats/prompts?')); + release(); + await completed; + await expect(page.locator('#tagPromptsGrid')).not.toContainText('Cat portrait'); +}); + +test('gallery infinite scroll appends a real backend page while retaining existing cards', async ({ page, request }) => { + await expect(await request.post('/__test__/reset', { data: { extra_images: 108 } })).toBeOK(); + await page.goto('/prompt_manager/gallery'); + await expect(page.locator('#galleryGrid .image-item')).toHaveCount(100); + await page.locator('#settingsBtn').click(); + await page.locator('#infiniteScrollToggle').check(); + await page.locator('#saveSettingsBtn').click(); + await expect(page.locator('#settingsModal')).toBeHidden(); + const first = page.locator('#galleryGrid .image-item').first(); + await first.evaluate(element => { element.dataset.preserved = 'yes'; }); + await page.locator('#galleryScroll').evaluate(element => { element.scrollTop = element.scrollHeight; }); + await expect(page.locator('#galleryGrid .image-item')).toHaveCount(110); + await expect(first).toHaveAttribute('data-preserved', 'yes'); +}); diff --git a/tests/js/admin-metadata.test.js b/tests/js/admin-metadata.test.js index 68abb6d..690fca7 100644 --- a/tests/js/admin-metadata.test.js +++ b/tests/js/admin-metadata.test.js @@ -10,7 +10,7 @@ function fixture() { getElementById: id => id === 'metadata-content' ? panel : null }, window: {}, console: { error() {} }, }); - const Admin = vm.runInContext(fs.readFileSync(path.join(__dirname, '../../web/js/admin.js'), 'utf8') + '\nPromptAdmin;', context); + const Admin = vm.runInContext(fs.readFileSync(path.join(__dirname, '../../web/js/admin.js'), 'utf8') + '\nPromptAdmin;', context, { filename: path.join(__dirname, '../../web/js/admin.js') }); const manager = Object.create(Admin.prototype); manager.extractImageMetadata = url => new Promise((resolve, reject) => requests.set(url, { resolve, reject })); manager.updateMetadataSidebar = metadata => { panel.innerHTML = metadata?.imagePath || 'No metadata'; }; diff --git a/tests/js/gallery-metadata.test.js b/tests/js/gallery-metadata.test.js index 3d4f16e..a55c9db 100644 --- a/tests/js/gallery-metadata.test.js +++ b/tests/js/gallery-metadata.test.js @@ -13,7 +13,7 @@ function deferred() { function fixture() { const context = vm.createContext({ document: { addEventListener() {}, getElementById() {} }, window: {}, console: { error() {} }, escapeHtml: value => value }); - const Manager = vm.runInContext(fs.readFileSync(path.join(__dirname, '../../web/js/gallery.js'), 'utf8') + '\nGalleryManager;', context); + const Manager = vm.runInContext(fs.readFileSync(path.join(__dirname, '../../web/js/gallery.js'), 'utf8') + '\nGalleryManager;', context, { filename: path.join(__dirname, '../../web/js/gallery.js') }); const manager = Object.create(Manager.prototype); const requests = new Map(); manager.extractImageMetadata = url => { diff --git a/tests/js/gallery-performance.test.js b/tests/js/gallery-performance.test.js index 43d0e96..bfea267 100644 --- a/tests/js/gallery-performance.test.js +++ b/tests/js/gallery-performance.test.js @@ -47,7 +47,7 @@ function fixture(count = 2000) { ListState: { nextPageState: (state, next) => ({ ...state, ...next }) }, }); const source = fs.readFileSync(path.join(__dirname, '../../web/js/gallery.js'), 'utf8'); - const Manager = vm.runInContext(source + '\nGalleryManager;', context); + const Manager = vm.runInContext(source + '\nGalleryManager;', context, { filename: path.join(__dirname, '../../web/js/gallery.js') }); const manager = Object.create(Manager.prototype); Object.assign(manager, { images: Array.from({ length: count }, (_, i) => ({ filename: `${i}.png`, url: `/original/${i}.png`, thumbnail_url: `/thumb/${i}.jpg`, diff --git a/tests/js/gallery-review.test.js b/tests/js/gallery-review.test.js index 96a2600..32e27c1 100644 --- a/tests/js/gallery-review.test.js +++ b/tests/js/gallery-review.test.js @@ -24,7 +24,7 @@ function fixture() { return request.promise; } }, }); - const Manager = vm.runInContext(fs.readFileSync(path.join(__dirname, '../../web/js/gallery.js'), 'utf8') + '\nGalleryManager;', context); + const Manager = vm.runInContext(fs.readFileSync(path.join(__dirname, '../../web/js/gallery.js'), 'utf8') + '\nGalleryManager;', context, { filename: path.join(__dirname, '../../web/js/gallery.js') }); const manager = Object.create(Manager.prototype); const notifications = []; Object.assign(manager, { autoTagState: { reviewImages: [ diff --git a/tests/js/gallery-root-metadata.test.js b/tests/js/gallery-root-metadata.test.js index cf220c9..733fc35 100644 --- a/tests/js/gallery-root-metadata.test.js +++ b/tests/js/gallery-root-metadata.test.js @@ -13,7 +13,7 @@ test('gallery metadata requests retain root identity without including cache par return { ok: true, json: async () => ({ success: true, prompt: { text: url } }) }; } }, }); - const Manager = vm.runInContext(fs.readFileSync(path.join(__dirname, '../../web/js/gallery.js'), 'utf8') + '\nGalleryManager;', context); + const Manager = vm.runInContext(fs.readFileSync(path.join(__dirname, '../../web/js/gallery.js'), 'utf8') + '\nGalleryManager;', context, { filename: path.join(__dirname, '../../web/js/gallery.js') }); const manager = Object.create(Manager.prototype); manager.extractImageMetadata = async () => null; manager.displayMetadata = (metadata, panel) => { panel.prompt = metadata.positivePrompt; }; diff --git a/tests/js/gallery-thumbnails.test.js b/tests/js/gallery-thumbnails.test.js index 535e945..431beea 100644 --- a/tests/js/gallery-thumbnails.test.js +++ b/tests/js/gallery-thumbnails.test.js @@ -23,7 +23,7 @@ function fixture(status, settings = {}) { total: 10000, images: Array.from({ length: 50 }, () => ({ thumbnail_url: null })), }) }) }, }); - const Manager = vm.runInContext(fs.readFileSync(path.join(__dirname, '../../web/js/gallery.js'), 'utf8') + '\nGalleryManager;', context); + const Manager = vm.runInContext(fs.readFileSync(path.join(__dirname, '../../web/js/gallery.js'), 'utf8') + '\nGalleryManager;', context, { filename: path.join(__dirname, '../../web/js/gallery.js') }); const manager = Object.create(Manager.prototype); Object.assign(manager, { getSettings: () => ({ checkThumbnailsAtStartup: true, ...settings }), api: { get: async url => { requests.push(url); return status; } }, diff --git a/tests/js/tags-prompts-race.test.js b/tests/js/tags-prompts-race.test.js index b06ce68..d227ae0 100644 --- a/tests/js/tags-prompts-race.test.js +++ b/tests/js/tags-prompts-race.test.js @@ -10,7 +10,7 @@ function fixture() { requestAnimationFrame() {}, document: { getElementById: id => id === 'tagPromptsGrid' ? grid : null, querySelector: () => null, createElement: () => ({ appendChild() {} }) }, }); - const Manager = vm.runInContext(fs.readFileSync(path.join(__dirname, '../../web/js/tags-page.js'), 'utf8') + '\nTagsPageManager;', context); + const Manager = vm.runInContext(fs.readFileSync(path.join(__dirname, '../../web/js/tags-page.js'), 'utf8') + '\nTagsPageManager;', context, { filename: path.join(__dirname, '../../web/js/tags-page.js') }); const manager = Object.create(Manager.prototype); Object.assign(manager, { selectedTags: ['cats'], filterMode: 'and', promptLimit: 20, promptOffset: 0, isLoadingPrompts: false, prompts: [], hasMorePrompts: false, diff --git a/tests/vendor/frontend-coverage.test.mjs b/tests/vendor/frontend-coverage.test.mjs new file mode 100644 index 0000000..ba1845d --- /dev/null +++ b/tests/vendor/frontend-coverage.test.mjs @@ -0,0 +1,22 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { join } from 'node:path'; +const sourceRoot = join(process.cwd(), 'web/js'); +import { checkCoverage } from '../../tools/vendor/frontend-coverage.mjs'; +const metrics = value => Object.fromEntries(['lines', 'branches', 'functions', 'statements'].map(key => [key, { pct: value }])); +const summary = { total: metrics(90), [join(sourceRoot, 'a.js')]: metrics(85) }; +const baseline = { 'a.js': { lines: 80, branches: 80, functions: 80, statements: 80 } }; +test('coverage gate accepts measured files at or above their reviewed floor', () => { + assert.deepEqual(checkCoverage(summary, baseline, sourceRoot, ['a.js']), []); +}); +test('coverage gate rejects a regression even when the aggregate percentage is high', () => { + assert.match(checkCoverage({ ...summary, [join(sourceRoot, 'a.js')]: metrics(70) }, baseline, sourceRoot, ['a.js']).join('\n'), /a.js.*lines/); +}); +test('coverage gate rejects absent source files and unreviewed new files', () => { + assert.match(checkCoverage({ total: metrics(100) }, baseline, sourceRoot, ['a.js']).join('\n'), /missing.*a.js/i); + assert.match(checkCoverage(summary, baseline, sourceRoot, ['a.js', 'new.js']).join('\n'), /baseline.*new.js/i); +}); +test('coverage gate rejects invalid baselines and missing metrics', () => { + assert.throws(() => checkCoverage(summary, { 'a.js': { lines: -1 } }, sourceRoot, ['a.js']), /baseline/i); + assert.match(checkCoverage({ total: metrics(100), [join(sourceRoot, 'a.js')]: {} }, baseline, sourceRoot, ['a.js']).join('\n'), /lines/); +}); diff --git a/tools/vendor/README.md b/tools/vendor/README.md index 66f94f5..22cd817 100644 --- a/tools/vendor/README.md +++ b/tools/vendor/README.md @@ -28,6 +28,10 @@ version banners must agree. The lockfile records npm's archive integrity; `provenance.json` records the upstream source, package integrity, and SHA-256 of each copied file. A normal `npm ci --ignore-scripts` verifies archive integrity. +Full-page workflows, frontend coverage gates, and required merge checks are +documented in [TESTING.md](../../TESTING.md). The `viewerjs` CI check runs both +the fixture tests above and the real-page workflows on every PR. + ## Update ```sh diff --git a/tools/vendor/browser-coverage.mjs b/tools/vendor/browser-coverage.mjs new file mode 100644 index 0000000..222b2da --- /dev/null +++ b/tools/vendor/browser-coverage.mjs @@ -0,0 +1,22 @@ +import { mkdirSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { randomUUID } from 'node:crypto'; +import { repositoryRoot } from './vendor-viewerjs.mjs'; + +export async function startCoverage(page, browserName) { + if (process.env.PM_JS_COVERAGE && browserName === 'chromium') { + await page.coverage.startJSCoverage({ resetOnNavigation: false }); + } +} +export async function saveCoverage(page, browserName) { + if (!process.env.PM_JS_COVERAGE || browserName !== 'chromium') return; + const entries = await page.coverage.stopJSCoverage(); + const result = []; + for (const entry of entries) { + const match = entry.url.match(/\/prompt_manager\/js\/([a-z0-9-]+\.js)(?:\?.*)?$/i); + if (match) result.push({ ...entry, url: pathToFileURL(join(repositoryRoot, 'web/js', match[1])).href }); + } + mkdirSync(process.env.PM_JS_COVERAGE, { recursive: true }); + writeFileSync(join(process.env.PM_JS_COVERAGE, `browser-${randomUUID()}.json`), JSON.stringify({ result })); +} diff --git a/tools/vendor/frontend-coverage-baseline.json b/tools/vendor/frontend-coverage-baseline.json new file mode 100644 index 0000000..9e5a107 --- /dev/null +++ b/tools/vendor/frontend-coverage-baseline.json @@ -0,0 +1,104 @@ +{ + "admin.js": { + "lines": 24, + "branches": 53, + "functions": 20, + "statements": 24 + }, + "api-client.js": { + "lines": 98, + "branches": 94, + "functions": 98, + "statements": 98 + }, + "comfy-metadata.js": { + "lines": 97, + "branches": 90, + "functions": 98, + "statements": 97 + }, + "data-actions.js": { + "lines": 98, + "branches": 98, + "functions": 98, + "statements": 98 + }, + "gallery-settings.js": { + "lines": 98, + "branches": 90, + "functions": 98, + "statements": 98 + }, + "gallery.js": { + "lines": 39, + "branches": 63, + "functions": 43, + "statements": 39 + }, + "html-escape.js": { + "lines": 98, + "branches": 98, + "functions": 98, + "statements": 98 + }, + "image-helpers.js": { + "lines": 96, + "branches": 91, + "functions": 98, + "statements": 96 + }, + "list-state.js": { + "lines": 98, + "branches": 92, + "functions": 98, + "statements": 98 + }, + "png-metadata.js": { + "lines": 98, + "branches": 90, + "functions": 98, + "statements": 98 + }, + "prompt-list-sort.js": { + "lines": 98, + "branches": 98, + "functions": 98, + "statements": 98 + }, + "scan-progress.js": { + "lines": 97, + "branches": 91, + "functions": 98, + "statements": 97 + }, + "sse-stream.js": { + "lines": 98, + "branches": 90, + "functions": 98, + "statements": 98 + }, + "tag-autocomplete.js": { + "lines": 98, + "branches": 94, + "functions": 98, + "statements": 98 + }, + "tags-page.js": { + "lines": 57, + "branches": 48, + "functions": 56, + "statements": 57 + }, + "theme-bridge.js": { + "lines": 90, + "branches": 53, + "functions": 98, + "statements": 90 + }, + "view-router.js": { + "lines": 98, + "branches": 98, + "functions": 98, + "statements": 98 + } +} diff --git a/tools/vendor/frontend-coverage.mjs b/tools/vendor/frontend-coverage.mjs new file mode 100644 index 0000000..e3ceae4 --- /dev/null +++ b/tools/vendor/frontend-coverage.mjs @@ -0,0 +1,76 @@ +import { readFileSync, readdirSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { dirname, join, relative } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { spawnSync } from 'node:child_process'; +import { repositoryRoot } from './vendor-viewerjs.mjs'; + +const metrics = ['lines', 'branches', 'functions', 'statements']; +export function checkCoverage(summary, baseline, sourceRoot, files) { + const failures = []; + for (const file of files) { + const floor = baseline[file]; + if (!floor) { failures.push(`Missing baseline for ${file}`); continue; } + const measured = summary[join(sourceRoot, file)]; + if (!measured) { failures.push(`Missing coverage for ${file}`); continue; } + for (const metric of metrics) { + if (!Number.isFinite(floor[metric]) || floor[metric] < 0 || floor[metric] > 100) { + throw new Error(`Invalid baseline for ${file}: ${metric}`); + } + if (!Number.isFinite(measured[metric]?.pct) || measured[metric].pct < floor[metric]) { + failures.push(`${file} ${metric}: ${measured[metric]?.pct ?? 'missing'} < ${floor[metric]}`); + } + } + } + return failures; +} + +function run(args, env = {}) { + const result = spawnSync(process.execPath, args, { cwd: repositoryRoot, stdio: 'inherit', + env: { ...process.env, ...env } }); + if (result.status !== 0) throw new Error(`Command failed (${result.status}): ${args.join(' ')}`); +} + +async function main() { + const tools = dirname(fileURLToPath(import.meta.url)); + const reportRoot = join(repositoryRoot, 'coverage/js'); + const raw = join(reportRoot, 'raw'); + const sourceRoot = join(repositoryRoot, 'web/js'); + const files = readdirSync(sourceRoot).filter(file => file.endsWith('.js')).sort(); + rmSync(reportRoot, { recursive: true, force: true }); + mkdirSync(raw, { recursive: true }); + const tests = readdirSync(join(repositoryRoot, 'tests/js')).filter(file => file.endsWith('.test.js')).map(file => `tests/js/${file}`); + run(['--test', ...tests], { NODE_V8_COVERAGE: raw }); + const playwright = join(tools, 'node_modules/@playwright/test/cli.js'); + run([playwright, 'test', '--config', join(tools, 'playwright.config.mjs')]); + run([playwright, 'test', '--config', join(tools, 'playwright.workflows.config.mjs'), + '--project', 'chromium', '--project', 'touch'], { PM_JS_COVERAGE: raw }); + run([join(tools, 'node_modules/c8/bin/c8.js'), 'report', '--all', '--src', sourceRoot, + '--include', 'web/js/*.js', '--temp-directory', raw, '--reports-dir', reportRoot, + '--reporter', 'text', '--reporter', 'json-summary', '--reporter', 'html']); + const summary = JSON.parse(readFileSync(join(reportRoot, 'coverage-summary.json'), 'utf8')); + const baselinePath = join(tools, 'frontend-coverage-baseline.json'); + if (process.argv.includes('--update-baseline')) { + const previous = JSON.parse(readFileSync(baselinePath, 'utf8')); + const failures = checkCoverage(summary, previous, sourceRoot, files.filter(file => previous[file])); + if (failures.length) throw new Error(`Baseline updates cannot lower coverage:\n${failures.join('\n')}`); + const next = Object.fromEntries(files.map(file => { + const measured = summary[join(sourceRoot, file)]; + if (!measured || metrics.some(metric => !Number.isFinite(measured[metric]?.pct))) { + throw new Error(`Missing coverage for ${file}`); + } + if (Object.keys(previous).length && !previous[file] && measured.lines.pct < 80) { + throw new Error(`New frontend files need at least 80% line coverage: ${file}`); + } + return [file, Object.fromEntries(metrics.map(metric => [metric, Math.max(previous[file]?.[metric] || 0, Math.floor(measured[metric].pct) - 2, 0)]))]; + })); + writeFileSync(baselinePath, JSON.stringify(next, null, 2) + '\n'); + } else { + const baseline = JSON.parse(readFileSync(baselinePath, 'utf8')); + const failures = checkCoverage(summary, baseline, sourceRoot, files); + if (failures.length) throw new Error(`Frontend coverage regressed:\n${failures.join('\n')}`); + } + console.log(`Frontend coverage passed for ${files.length} files. Report: ${relative(repositoryRoot, reportRoot)}/index.html`); +} +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main().catch(error => { console.error(error.message); process.exitCode = 1; }); +} diff --git a/tools/vendor/package-lock.json b/tools/vendor/package-lock.json index 8cba944..2567e97 100644 --- a/tools/vendor/package-lock.json +++ b/tools/vendor/package-lock.json @@ -9,9 +9,58 @@ "version": "1.0.0", "devDependencies": { "@playwright/test": "1.64.1", + "c8": "12.0.0", "viewerjs": "1.15.2" } }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz", + "integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, "node_modules/@playwright/test": { "version": "1.64.1", "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.64.1.tgz", @@ -28,6 +77,443 @@ "node": ">=20" } }, + "node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", + "dev": true, + "license": "MIT" + }, + "node_modules/ansi-regex": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.4.0.tgz", + "integrity": "sha512-KzTVk2tCWAHtYrvvvaP8bJKJq2pVinhLcGEQdtLIYPbmNGNyYe8QwNaTUYQp2J7/vIsUKt5QCqAfUkYyG9DkOw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/c8": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/c8/-/c8-12.0.0.tgz", + "integrity": "sha512-4zpJvrd1nKWutnnKC2pXkFmb6iM1l+ffN//o1CzlTNwW7GSOs9a1xrLqkC48nU8oEkjmPZLPiwMsIaOvoF4Pqg==", + "dev": true, + "license": "ISC", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.1", + "@istanbuljs/schema": "^0.1.3", + "find-up": "^5.0.0", + "foreground-child": "^3.1.1", + "istanbul-lib-coverage": "^3.2.0", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.1.6", + "test-exclude": "^8.0.0", + "v8-to-istanbul": "^9.0.0", + "yargs": "^18.0.0", + "yargs-parser": "^21.1.1" + }, + "bin": { + "c8": "bin/c8.js" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + }, + "peerDependencies": { + "monocart-coverage-reports": "^2" + }, + "peerDependenciesMeta": { + "monocart-coverage-reports": { + "optional": true + } + } + }, + "node_modules/cliui": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", + "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cliui/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.7.0.tgz", + "integrity": "sha512-XjH1AECxf0giL2V1aU8vKyRR2ppRUb5c0EvT7zuJTokQ74bNo52zOtghqdWIqrhUD79fo3x0WfKZdOqxF6LG1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/playwright": { "version": "1.64.1", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.1.tgz", @@ -57,12 +543,250 @@ "node": ">=20" } }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/string-width": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.3.0.tgz", + "integrity": "sha512-ZbmZM0JCihQN91dWnxoipT2KOEyHqEyfRXUyjuRhW8b/xnqPDoq4gWEVApTVa9db2wN8mmoikgFBbjh71+cGeQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/test-exclude": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-8.0.0.tgz", + "integrity": "sha512-ZOffsNrXYggvU1mDGHk54I96r26P8SyMjO5slMKSc7+IWmtB/MQKnEC2fP51imB3/pT6YK5cT5E8f+Dd9KdyOQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^13.0.6", + "minimatch": "^10.2.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/v8-to-istanbul": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", + "integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==", + "dev": true, + "license": "ISC", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.12", + "@types/istanbul-lib-coverage": "^2.0.1", + "convert-source-map": "^2.0.0" + }, + "engines": { + "node": ">=10.12.0" + } + }, "node_modules/viewerjs": { "version": "1.15.2", "resolved": "https://registry.npmjs.org/viewerjs/-/viewerjs-1.15.2.tgz", "integrity": "sha512-CYEqSFfPUSnmn1PjrhYxXOScaUC7n8+NpSqI+MrOSM8wcKPe35MlwQYwksuD5rjFkYv/ePmY5NsUa51uRNN2jA==", "dev": true, "license": "MIT" + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "18.2.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.2.0.tgz", + "integrity": "sha512-9OpKOLeaoNFecEp7P6iYbzze/5CqWoH7N3SMs/6y1XF4nMvFMspEGZzJ6uFi9MmQwXhyzqYoSEKO3tvA3Z/o2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^9.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "string-width": "^8.2.1", + "y18n": "^5.0.5", + "yargs-parser": "^22.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs/node_modules/yargs-parser": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } } } } diff --git a/tools/vendor/package.json b/tools/vendor/package.json index 42e47b9..13acd0e 100644 --- a/tools/vendor/package.json +++ b/tools/vendor/package.json @@ -8,10 +8,14 @@ "check": "node vendor-viewerjs.mjs --check", "update": "node update-viewerjs.mjs", "test": "node --test ../../tests/vendor/*.test.mjs", - "test:browser": "playwright test --config playwright.config.mjs" + "test:browser": "playwright test --config playwright.config.mjs", + "test:workflows": "playwright test --config playwright.workflows.config.mjs", + "test:coverage": "node frontend-coverage.mjs", + "coverage:update": "node frontend-coverage.mjs --update-baseline" }, "devDependencies": { "@playwright/test": "1.64.1", + "c8": "12.0.0", "viewerjs": "1.15.2" } } diff --git a/tools/vendor/playwright.config.mjs b/tools/vendor/playwright.config.mjs index 4e26aed..5d291a7 100644 --- a/tools/vendor/playwright.config.mjs +++ b/tools/vendor/playwright.config.mjs @@ -2,6 +2,7 @@ import { defineConfig } from '@playwright/test'; export default defineConfig({ testDir: './tests', + outputDir: './test-results/viewer', testMatch: '*.spec.mjs', fullyParallel: true, workers: 2, diff --git a/tools/vendor/playwright.workflows.config.mjs b/tools/vendor/playwright.workflows.config.mjs new file mode 100644 index 0000000..313ca8e --- /dev/null +++ b/tools/vendor/playwright.workflows.config.mjs @@ -0,0 +1,27 @@ +import { defineConfig, devices } from '@playwright/test'; +import { repositoryRoot } from './vendor-viewerjs.mjs'; +const port = Number(process.env.PM_TEST_PORT || 8765); +export default defineConfig({ + testDir: '../../tests/browser', + outputDir: './test-results/workflows', + testMatch: '*.spec.mjs', + workers: 1, + fullyParallel: false, + forbidOnly: Boolean(process.env.CI), + retries: 0, + reporter: 'list', + use: { baseURL: `http://127.0.0.1:${port}`, trace: 'retain-on-failure', screenshot: 'only-on-failure' }, + webServer: { + command: `node tools/vendor/start-browser-server.mjs --port ${port}`, + cwd: repositoryRoot, + url: `http://127.0.0.1:${port}/prompt_manager/test`, + reuseExistingServer: false, + gracefulShutdown: { signal: 'SIGTERM', timeout: 5000 }, + }, + projects: [ + { name: 'chromium', use: { browserName: 'chromium' } }, + { name: 'firefox', use: { browserName: 'firefox' } }, + { name: 'webkit', use: { browserName: 'webkit' } }, + { name: 'touch', use: { ...devices['Pixel 7'], browserName: 'chromium' } }, + ], +}); diff --git a/tools/vendor/start-browser-server.mjs b/tools/vendor/start-browser-server.mjs new file mode 100644 index 0000000..f41dc62 --- /dev/null +++ b/tools/vendor/start-browser-server.mjs @@ -0,0 +1,8 @@ +import { spawn } from 'node:child_process'; +const child = spawn(process.env.PM_TEST_PYTHON || 'python', + ['-m', 'tests.browser.server', ...process.argv.slice(2)], { stdio: 'inherit' }); +child.on('error', error => { console.error(error.message); process.exitCode = 1; }); +child.on('exit', code => { process.exitCode = code ?? 1; }); +for (const signal of ['SIGTERM', 'SIGINT']) { + process.once(signal, () => child.kill(signal)); +}