diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 6b8c540e..867a5eb5 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -3,11 +3,17 @@ name: Automated Tests on: pull_request: push: - branches: [main, dev] workflow_dispatch: schedule: - cron: "0 2 * * *" +permissions: + contents: read + +concurrency: + group: frontend-tests-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: format-tests: runs-on: ubuntu-latest @@ -77,7 +83,7 @@ jobs: run: npx playwright test --grep @smoke --project="${{ matrix.project }}" - name: Upload Playwright report - if: always() + if: failure() uses: actions/upload-artifact@v4 with: name: playwright-report-smoke-${{ matrix.project }} @@ -88,7 +94,7 @@ jobs: retention-days: 7 e2e-full: - if: github.event_name == 'schedule' || github.ref == 'refs/heads/main' + if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || github.ref == 'refs/heads/main' needs: build-and-unit runs-on: ubuntu-latest steps: @@ -111,7 +117,7 @@ jobs: run: npm run test:e2e:ci - name: Upload Playwright report - if: always() + if: failure() uses: actions/upload-artifact@v4 with: name: playwright-report-full diff --git a/README.md b/README.md index 62deb43a..62d25076 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,41 @@ npm run dev npm run build ``` +## Playwright Batched Chromium Runs + +Run default e2e tests in deterministic 25-test shards across `chromium-desktop` and `chromium-mobile`: + +```sh +npm run test:e2e:batched:chromium +``` + +Run the same flow and automatically re-run failed shards with `PLAYWRIGHT_WORKERS=1`: + +```sh +npm run test:e2e:batched:chromium:rerun-failed +``` + +Optional overrides: + +```sh +PLAYWRIGHT_BATCH_SIZE=25 +PLAYWRIGHT_BATCH_WORKERS=2 +PLAYWRIGHT_BATCH_DEV_PORT=5193 +``` + +You can also forward Playwright args: + +```sh +npm run test:e2e:batched:chromium -- --grep @smoke +``` + +Artifacts and summaries: +- `output/playwright/batched-chromium/last-run.json` +- `output/playwright/batched-chromium/failed-shards.json` +- `output/playwright/batched-chromium/report-index.html` +- `output/playwright/batched-chromium-shard--of-/report/index.html` +- `output/playwright/batched-chromium-rerun-shard--of-/report/index.html` + ## Bubblewrap (TWA) Build and Install To build and install the Trusted Web Activity (TWA) using Bubblewrap, use the following commands: diff --git a/package.json b/package.json index c1670d9e..3e4b4157 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,8 @@ "test:e2e:i18n:views": "playwright test tests/e2e/i18n.views.spec.ts --project=chromium-desktop", "test:e2e:ci": "node scripts/run-playwright-ci-parallel.mjs", "test:e2e:ci:serial": "playwright test --reporter=line,html", + "test:e2e:batched:chromium": "node scripts/run-playwright-batched-chromium.mjs", + "test:e2e:batched:chromium:rerun-failed": "node scripts/run-playwright-batched-chromium.mjs --rerun-failed", "test:e2e:smoke": "playwright test --grep @smoke --project=chromium-desktop --project=chromium-mobile", "test:e2e:prod": "playwright test --config=playwright.prod.config.ts", "test:e2e:live": "playwright test --config=playwright.live.config.ts", @@ -46,7 +48,7 @@ "@xterm/addon-fit": "^0.11.0", "animate.css": "^4.1.1", "apexcharts": "^5.10.4", - "axios": "1.13.5", + "axios": "1.15.0", "buefy": "^3.0.3", "bulma": "^1.0.2", "bulma-block-list": "^1.1.0", @@ -91,7 +93,7 @@ "otpauth": "^9.5.0", "prettier": "2.8.8", "sass-embedded": "^1.81.0", - "vite": "7.1.11", + "vite": "8.0.5", "vite-plugin-pwa": "^1.0.2", "vite-plugin-vue-devtools": "^7.5.4", "vitest": "^2.1.9" diff --git a/scripts/run-playwright-batched-chromium.mjs b/scripts/run-playwright-batched-chromium.mjs new file mode 100644 index 00000000..a0e862c7 --- /dev/null +++ b/scripts/run-playwright-batched-chromium.mjs @@ -0,0 +1,747 @@ +import { execFile, spawn } from "node:child_process"; +import fs from "node:fs/promises"; +import path from "node:path"; +import readline from "node:readline"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); +const workingDirectory = process.cwd(); +const playwrightCliPath = path.join(workingDirectory, "node_modules", "@playwright", "test", "cli.js"); +const projects = ["chromium-desktop", "chromium-mobile"]; +const outputDirectory = path.join(workingDirectory, "output", "playwright", "batched-chromium"); +const testListDirectory = path.join(outputDirectory, "test-lists"); + +const args = parseArgs(process.argv.slice(2)); +const batchSize = normalizePositiveInt(args.batchSize ?? process.env.PLAYWRIGHT_BATCH_SIZE, 25); +const shardWorkers = normalizePositiveInt( + args.workers ?? process.env.PLAYWRIGHT_BATCH_WORKERS ?? process.env.PLAYWRIGHT_WORKERS, + 2 +); +const devPort = normalizePositiveInt(args.port ?? process.env.PLAYWRIGHT_BATCH_DEV_PORT ?? process.env.PLAYWRIGHT_DEV_PORT, 5193); +const baseURL = `http://localhost:${devPort}`; + +let activePlaywrightChild = null; +let activeDevServerProcess = null; +let isShuttingDown = false; + +for (const signal of ["SIGINT", "SIGTERM"]) { + process.on(signal, () => { + void shutdown(signal).finally(() => { + process.exit(130); + }); + }); +} + +function parseArgs(rawArgs) { + const parsed = { + rerunFailed: false, + dryRun: false, + help: false, + batchSize: null, + workers: null, + port: null, + forwardedArgs: [], + }; + + for (let index = 0; index < rawArgs.length; index += 1) { + const value = rawArgs[index]; + + if (value === "--") { + parsed.forwardedArgs.push(...rawArgs.slice(index + 1)); + break; + } + + if (value === "--rerun-failed") { + parsed.rerunFailed = true; + continue; + } + + if (value === "--dry-run") { + parsed.dryRun = true; + continue; + } + + if (value === "--help" || value === "-h") { + parsed.help = true; + continue; + } + + if (value === "--batch-size" && rawArgs[index + 1]) { + parsed.batchSize = rawArgs[index + 1]; + index += 1; + continue; + } + + if (value.startsWith("--batch-size=")) { + parsed.batchSize = value.slice("--batch-size=".length); + continue; + } + + if (value === "--workers" && rawArgs[index + 1]) { + parsed.workers = rawArgs[index + 1]; + index += 1; + continue; + } + + if (value.startsWith("--workers=")) { + parsed.workers = value.slice("--workers=".length); + continue; + } + + if (value === "--port" && rawArgs[index + 1]) { + parsed.port = rawArgs[index + 1]; + index += 1; + continue; + } + + if (value.startsWith("--port=")) { + parsed.port = value.slice("--port=".length); + continue; + } + + parsed.forwardedArgs.push(value); + } + + return parsed; +} + +function normalizePositiveInt(value, fallback) { + const parsed = Number(value); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; +} + +function stripAnsi(input) { + return input.replace(/\u001b\[[0-9;]*m/g, ""); +} + +function formatDuration(durationMs) { + return `${(durationMs / 1000).toFixed(1)}s`; +} + +function chunkTests(testLines, size) { + const chunks = []; + for (let index = 0; index < testLines.length; index += size) { + const shard = Math.floor(index / size) + 1; + const lines = testLines.slice(index, index + size); + chunks.push({ + shard, + start: index + 1, + end: index + lines.length, + lines, + }); + } + return chunks; +} + +function printUsage() { + console.log(` +Playwright batched chromium runner + +Usage: + node scripts/run-playwright-batched-chromium.mjs [options] [-- ] + +Options: + --batch-size Tests per chunk. Default: 25 + --workers PLAYWRIGHT_WORKERS for primary runs. Default: 2 + --port Vite dev server port. Default: 5193 + --rerun-failed Re-run failed chunks with PLAYWRIGHT_WORKERS=1 + --dry-run Compute and print chunk plan without executing tests + -h, --help Show help +`); +} + +function validateForwardedArgs(forwardedArgs) { + for (const value of forwardedArgs) { + if (value === "--list" || value.startsWith("--list=")) { + throw new Error("--list is managed by this script and cannot be forwarded."); + } + + if (value === "--project" || value.startsWith("--project=")) { + throw new Error("--project is managed by this script and cannot be forwarded."); + } + + if (value === "--shard" || value.startsWith("--shard=")) { + throw new Error("--shard is managed by this script and cannot be forwarded."); + } + + if (value === "--test-list" || value.startsWith("--test-list=")) { + throw new Error("--test-list is managed by this script and cannot be forwarded."); + } + + if (value === "--test-list-invert" || value.startsWith("--test-list-invert=")) { + throw new Error("--test-list-invert is managed by this script and cannot be forwarded."); + } + } +} + +function prefixStream(stream, prefix) { + const lineReader = readline.createInterface({ input: stream }); + lineReader.on("line", (line) => { + process.stdout.write(`[${prefix}] ${line}\n`); + }); +} + +async function getListeningProcessOnWindows(port) { + const { stdout } = await execFileAsync("netstat", ["-ano", "-p", "tcp"], { cwd: workingDirectory }); + const match = stdout.match(new RegExp(`^\\s*TCP\\s+[^\\s]+:${port}\\s+[^\\s]+\\s+LISTENING\\s+(\\d+)\\s*$`, "mi")); + if (!match) { + return null; + } + + const pid = Number(match[1]); + const command = `Get-CimInstance Win32_Process -Filter 'ProcessId = ${pid}' | Select-Object -ExpandProperty CommandLine`; + const processResult = await execFileAsync("powershell", ["-NoProfile", "-Command", command], { + cwd: workingDirectory, + }).catch(() => ({ stdout: "" })); + + return { + id: pid, + commandLine: processResult.stdout.trim(), + }; +} + +async function getListeningProcessOnUnix(port) { + try { + const { stdout } = await execFileAsync("lsof", ["-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-Fp", "-Fc"], { + cwd: workingDirectory, + }); + const pidMatch = stdout.match(/^p(\d+)$/m); + const commandMatch = stdout.match(/^c(.+)$/m); + if (!pidMatch) { + return null; + } + + return { + id: Number(pidMatch[1]), + commandLine: commandMatch ? commandMatch[1] : "", + }; + } catch { + return null; + } +} + +async function getListeningProcess(port) { + if (process.platform === "win32") { + return getListeningProcessOnWindows(port); + } + + return getListeningProcessOnUnix(port); +} + +async function killProcessTree(pid) { + if (!pid) { + return; + } + + if (process.platform === "win32") { + await execFileAsync("taskkill", ["/PID", String(pid), "/T", "/F"], { cwd: workingDirectory }).catch(() => {}); + return; + } + + try { + process.kill(-pid, "SIGTERM"); + } catch { + try { + process.kill(pid, "SIGTERM"); + } catch { + // ignore + } + } +} + +async function waitForServerReady(url, timeoutMs = 120_000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + const response = await fetch(url, { redirect: "manual" }); + if (response.status < 500) { + return; + } + } catch { + // keep polling + } + + await new Promise((resolve) => setTimeout(resolve, 1000)); + } + + throw new Error(`Timed out waiting for Vite server at ${url}.`); +} + +async function startDevServer() { + const existingProcess = await getListeningProcess(devPort); + if (existingProcess) { + if (!/vite(?:\.js)?/i.test(existingProcess.commandLine || "")) { + throw new Error(`Port ${devPort} is already in use by a non-Vite process: ${existingProcess.commandLine}`); + } + + await killProcessTree(existingProcess.id); + await new Promise((resolve) => setTimeout(resolve, 1000)); + } + + activeDevServerProcess = + process.platform === "win32" + ? spawn( + "cmd.exe", + ["/d", "/s", "/c", `npm.cmd run dev -- --host localhost --port ${devPort} --strictPort`], + { + cwd: workingDirectory, + env: { + ...process.env, + PLAYWRIGHT: "1", + }, + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, + } + ) + : spawn("npm", ["run", "dev", "--", "--host", "localhost", "--port", String(devPort), "--strictPort"], { + cwd: workingDirectory, + env: { + ...process.env, + PLAYWRIGHT: "1", + }, + stdio: ["ignore", "pipe", "pipe"], + }); + + prefixStream(activeDevServerProcess.stdout, "dev-server"); + prefixStream(activeDevServerProcess.stderr, "dev-server:err"); + + await waitForServerReady(baseURL); + console.log(`Vite dev server is ready at ${baseURL}.`); +} + +async function stopDevServer() { + if (!activeDevServerProcess) { + return; + } + + await killProcessTree(activeDevServerProcess.pid); + activeDevServerProcess = null; +} + +async function runPlaywrightCommand(commandArgs, envOverrides = {}, inheritOutput = false) { + if (inheritOutput) { + return new Promise((resolve) => { + activePlaywrightChild = spawn(process.execPath, [playwrightCliPath, ...commandArgs], { + cwd: workingDirectory, + env: { + ...process.env, + ...envOverrides, + }, + stdio: "inherit", + windowsHide: true, + }); + + activePlaywrightChild.on("close", (code) => { + activePlaywrightChild = null; + resolve({ + code: code ?? 1, + stdout: "", + stderr: "", + }); + }); + }); + } + + try { + const result = await execFileAsync(process.execPath, [playwrightCliPath, ...commandArgs], { + cwd: workingDirectory, + env: { + ...process.env, + ...envOverrides, + }, + maxBuffer: 30 * 1024 * 1024, + windowsHide: true, + }); + + return { + code: 0, + stdout: result.stdout, + stderr: result.stderr, + }; + } catch (error) { + const stdout = typeof error.stdout === "string" ? error.stdout : ""; + const stderr = typeof error.stderr === "string" ? error.stderr : ""; + const code = typeof error.code === "number" ? error.code : 1; + return { + code, + stdout, + stderr, + }; + } +} + +async function preflightCollectTests(forwardedArgs) { + const commandArgs = [ + "test", + ...projects.flatMap((project) => ["--project", project]), + "--list", + ...forwardedArgs, + ]; + const result = await runPlaywrightCommand( + commandArgs, + { + PLAYWRIGHT_BASE_URL: baseURL, + PLAYWRIGHT: "1", + }, + false + ); + + const combinedOutput = stripAnsi(`${result.stdout}\n${result.stderr}`); + const totalMatch = combinedOutput.match(/Total:\s+(\d+)\s+tests?/i); + if (!totalMatch) { + throw new Error(`Failed to parse Playwright test count from --list output.\n${combinedOutput}`); + } + + const listedTests = combinedOutput + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line.startsWith("[")); + + const parsedTotal = Number(totalMatch[1]); + if (listedTests.length === 0) { + throw new Error("Playwright --list returned zero runnable tests."); + } + + if (listedTests.length !== parsedTotal) { + console.warn( + `Warning: --list reported ${parsedTotal} tests but ${listedTests.length} test lines were parsed. Using parsed lines.` + ); + } + + return { + totalTests: listedTests.length, + listedTests, + }; +} + +async function writeTestListFile(passLabel, chunk, totalShards) { + await fs.mkdir(testListDirectory, { recursive: true }); + const filename = `${passLabel}-shard-${chunk.shard}-of-${totalShards}.txt`; + const targetPath = path.join(testListDirectory, filename); + await fs.writeFile(targetPath, `${chunk.lines.join("\n")}\n`, "utf8"); + return targetPath; +} + +async function runShard({ chunk, totalShards, workers, passLabel, forwardedArgs }) { + const testListPath = await writeTestListFile(passLabel, chunk, totalShards); + const commandArgs = [ + "test", + ...projects.flatMap((project) => ["--project", project]), + "--test-list", + testListPath, + ...forwardedArgs, + ]; + + const isRerun = passLabel === "rerun"; + const artifactNamespace = isRerun + ? `batched-chromium-rerun-shard-${chunk.shard}-of-${totalShards}` + : `batched-chromium-shard-${chunk.shard}-of-${totalShards}`; + + console.log( + `[${passLabel}] Running shard ${chunk.shard}/${totalShards} (${chunk.start}-${chunk.end}, ${chunk.lines.length} tests, workers=${workers})` + ); + + const startedAt = Date.now(); + const result = await runPlaywrightCommand( + commandArgs, + { + PLAYWRIGHT_BASE_URL: baseURL, + PLAYWRIGHT_DEV_PORT: String(devPort), + PLAYWRIGHT_WORKERS: String(workers), + PLAYWRIGHT_ARTIFACT_NAMESPACE: artifactNamespace, + PLAYWRIGHT_REPORTER_MODE: "line-html", + PLAYWRIGHT: "1", + }, + true + ); + + return { + pass: passLabel, + shard: chunk.shard, + totalShards, + tests: chunk.lines.length, + start: chunk.start, + end: chunk.end, + workers, + code: result.code, + durationMs: Date.now() - startedAt, + artifactNamespace, + testListPath: path.relative(workingDirectory, testListPath), + }; +} + +function printSummary(primaryResults, rerunResults) { + const rows = [...primaryResults, ...rerunResults]; + console.log("\nBatched Chromium Summary"); + console.log("pass\tshard\tstatus\ttests\tworkers\tduration\tartifact"); + for (const row of rows) { + const status = row.code === 0 ? "passed" : "failed"; + console.log( + `${row.pass}\t${row.shard}/${row.totalShards}\t${status}\t${row.start}-${row.end} (${row.tests})\t${row.workers}\t${formatDuration(row.durationMs)}\t${row.artifactNamespace}` + ); + } +} + +async function writeRunArtifacts({ totalTests, totalShards, chunks, primaryResults, rerunResults }) { + const failedPrimaryShards = primaryResults.filter((result) => result.code !== 0).map((result) => result.shard); + const failedRerunShards = rerunResults.filter((result) => result.code !== 0).map((result) => result.shard); + + await fs.mkdir(outputDirectory, { recursive: true }); + + const runReport = { + generatedAt: new Date().toISOString(), + baseURL, + projects, + batchSize, + totalTests, + totalShards, + primaryWorkers: shardWorkers, + rerunFailed: args.rerunFailed, + forwardedArgs: args.forwardedArgs, + chunks: chunks.map((chunk) => ({ + shard: chunk.shard, + start: chunk.start, + end: chunk.end, + tests: chunk.lines.length, + })), + primaryResults, + rerunResults, + failedPrimaryShards, + failedRerunShards, + }; + + await fs.writeFile(path.join(outputDirectory, "last-run.json"), JSON.stringify(runReport, null, 2), "utf8"); + await fs.writeFile(path.join(outputDirectory, "failed-shards.json"), JSON.stringify(failedPrimaryShards, null, 2), "utf8"); + + const primaryRows = primaryResults + .map((result) => { + const status = result.code === 0 ? "passed" : "failed"; + const color = result.code === 0 ? "#166534" : "#991b1b"; + return ` + ${result.shard}/${result.totalShards} + ${result.start}-${result.end} (${result.tests}) + ${status} + ${result.workers} + ${formatDuration(result.durationMs)} + ${result.testListPath} + Open report +`; + }) + .join("\n"); + + const rerunRows = + rerunResults.length === 0 + ? "No rerun shards executed." + : rerunResults + .map((result) => { + const status = result.code === 0 ? "passed" : "failed"; + const color = result.code === 0 ? "#166534" : "#991b1b"; + return ` + ${result.shard}/${result.totalShards} + ${result.start}-${result.end} (${result.tests}) + ${status} + ${result.workers} + ${formatDuration(result.durationMs)} + ${result.testListPath} + Open report +`; + }) + .join("\n"); + + const html = ` + + + + Batched Chromium Playwright Report Index + + + +

Batched Chromium Playwright Reports

+

Total tests: ${totalTests} | Batch size: ${batchSize} | Shards: ${totalShards}

+

JSON summary: output/playwright/batched-chromium/last-run.json

+ +

Primary pass

+ + + + + + + + + + + + + +${primaryRows} + +
ShardTest rangeStatusWorkersDurationTest listReport
+ +

Rerun pass

+ + + + + + + + + + + + + +${rerunRows} + +
ShardTest rangeStatusWorkersDurationTest listReport
+ +`; + + await fs.writeFile(path.join(outputDirectory, "report-index.html"), html, "utf8"); +} + +async function shutdown(signal) { + if (isShuttingDown) { + return; + } + + isShuttingDown = true; + console.error(`Received ${signal}. Stopping active Playwright chunk and dev server...`); + + await Promise.all([ + killProcessTree(activePlaywrightChild?.pid), + killProcessTree(activeDevServerProcess?.pid), + ]); + + activePlaywrightChild = null; + activeDevServerProcess = null; +} + +async function main() { + if (args.help) { + printUsage(); + return; + } + + validateForwardedArgs(args.forwardedArgs); + await fs.access(playwrightCliPath); + + const preflight = await preflightCollectTests(args.forwardedArgs); + const chunks = chunkTests(preflight.listedTests, batchSize); + const totalShards = chunks.length; + + console.log( + `Batched Chromium preflight: ${preflight.totalTests} tests across ${projects.join(", ")} -> ${totalShards} shards at ${batchSize} tests/chunk.` + ); + + if (args.dryRun) { + await fs.mkdir(outputDirectory, { recursive: true }); + const dryRunFile = path.join(outputDirectory, "dry-run.json"); + await fs.writeFile( + dryRunFile, + JSON.stringify( + { + generatedAt: new Date().toISOString(), + baseURL, + projects, + batchSize, + shardWorkers, + totalTests: preflight.totalTests, + totalShards, + rerunFailed: args.rerunFailed, + forwardedArgs: args.forwardedArgs, + shards: chunks.map((chunk) => ({ + shard: chunk.shard, + start: chunk.start, + end: chunk.end, + tests: chunk.lines.length, + })), + }, + null, + 2 + ), + "utf8" + ); + console.log(`Dry run complete. Summary written to ${path.relative(workingDirectory, dryRunFile)}.`); + return; + } + + await startDevServer(); + + const primaryResults = []; + for (const chunk of chunks) { + primaryResults.push( + await runShard({ + chunk, + totalShards, + workers: shardWorkers, + passLabel: "primary", + forwardedArgs: args.forwardedArgs, + }) + ); + } + + const failedPrimaryShards = primaryResults.filter((result) => result.code !== 0).map((result) => result.shard); + const rerunResults = []; + + if (args.rerunFailed && failedPrimaryShards.length > 0) { + console.log(`\nRe-running failed chunks with workers=1: ${failedPrimaryShards.join(", ")}`); + for (const shard of failedPrimaryShards) { + const chunk = chunks[shard - 1]; + rerunResults.push( + await runShard({ + chunk, + totalShards, + workers: 1, + passLabel: "rerun", + forwardedArgs: args.forwardedArgs, + }) + ); + } + } + + printSummary(primaryResults, rerunResults); + await writeRunArtifacts({ + totalTests: preflight.totalTests, + totalShards, + chunks, + primaryResults, + rerunResults, + }); + await stopDevServer(); + + const failedRerunShards = rerunResults.filter((result) => result.code !== 0).map((result) => result.shard); + + if (failedPrimaryShards.length === 0) { + console.log("\nAll batched Chromium chunks passed."); + return; + } + + console.error(`\nPrimary failed shards: ${failedPrimaryShards.join(", ")}`); + if (args.rerunFailed) { + if (failedRerunShards.length > 0) { + console.error(`Rerun failed shards: ${failedRerunShards.join(", ")}`); + } else { + console.error("All failed shards passed during rerun. Treating this as flaky and returning non-zero."); + } + } + + process.exitCode = 1; +} + +await main().catch(async (error) => { + console.error(error instanceof Error ? error.message : error); + await stopDevServer(); + process.exitCode = 1; +}); diff --git a/scripts/start-playwright-dev-server.ps1 b/scripts/start-playwright-dev-server.ps1 new file mode 100644 index 00000000..952beeff --- /dev/null +++ b/scripts/start-playwright-dev-server.ps1 @@ -0,0 +1,45 @@ +$ErrorActionPreference = "Stop" + +param( + [int]$Port = 5173 +) + +function Get-ListeningProcess { + param([int]$TargetPort) + + $listener = Get-NetTCPConnection -LocalPort $TargetPort -State Listen -ErrorAction SilentlyContinue | + Select-Object -First 1 + + if (-not $listener) { + return $null + } + + $process = Get-CimInstance Win32_Process -Filter "ProcessId = $($listener.OwningProcess)" -ErrorAction SilentlyContinue + if (-not $process) { + return $null + } + + return [PSCustomObject]@{ + Id = $listener.OwningProcess + CommandLine = [string]($process.CommandLine ?? "") + } +} + +$existing = Get-ListeningProcess -TargetPort $Port +if ($existing) { + if ($existing.CommandLine -notmatch "vite(?:\.js)?") { + throw "Port $Port is already in use by a non-Vite process: $($existing.CommandLine)" + } + + Stop-Process -Id $existing.Id -Force + Start-Sleep -Seconds 1 +} + +$npm = if (Get-Command npm.cmd -ErrorAction SilentlyContinue) { + "npm.cmd" +} else { + "npm" +} + +& $npm run dev -- --host localhost --port $Port --strictPort +exit $LASTEXITCODE diff --git a/src/components/forms/department/pos/input/LicensePlateReg1Input.vue b/src/components/forms/department/pos/input/LicensePlateReg1Input.vue index 436bd35c..0634878b 100644 --- a/src/components/forms/department/pos/input/LicensePlateReg1Input.vue +++ b/src/components/forms/department/pos/input/LicensePlateReg1Input.vue @@ -549,7 +549,8 @@ const commitSelection = async (options = {}) => { if (normalizedOptions.clearDropdownResultsWithoutVehicle) { vehicles_matching.value = []; } - if (bookingMatches.length === 0) { + const hasSelectedCustomer = normalizeCustomerNumber(customer_id.value) !== null; + if (bookingMatches.length === 0 && !hasSelectedCustomer) { clearCustomerSelection(); } emitVehicleObject(null, plateOverride);