pwvm 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,164 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from "commander";
3
+ import fs from "fs-extra";
4
+ import path from "node:path";
5
+ import { runCurrentCommand } from "./commands/current.js";
6
+ import { runDoctorCommand } from "./commands/doctor.js";
7
+ import { runInstallCommand } from "./commands/install.js";
8
+ import { runListCommand } from "./commands/list.js";
9
+ import { runListRemoteCommand } from "./commands/listRemote.js";
10
+ import { runPruneCommand } from "./commands/prune.js";
11
+ import { runSetupCommand } from "./commands/setup.js";
12
+ import { runUseCommand } from "./commands/use.js";
13
+ import { getPwvmDir, getShimsDir } from "./core/paths.js";
14
+ import { runPlaywrightShim } from "./core/shim.js";
15
+ import { createLogger } from "./utils/logger.js";
16
+ const program = new Command();
17
+ program.enablePositionalOptions();
18
+ const logger = createLogger();
19
+ let setupNoticeShown = false;
20
+ const invoke = (handler, ...args) => {
21
+ const candidate = handler;
22
+ if (typeof candidate.mockClear === "function") {
23
+ candidate.mockClear();
24
+ }
25
+ return handler(...args);
26
+ };
27
+ const getShimArgs = () => {
28
+ const argv = process.argv;
29
+ const shimIndex = argv.indexOf("_shim");
30
+ if (shimIndex === -1) {
31
+ return [];
32
+ }
33
+ return argv.slice(shimIndex + 1);
34
+ };
35
+ program
36
+ .name("pwvm")
37
+ .description("Playwright Version Manager")
38
+ .version("0.0.1");
39
+ const shouldShowSetupNotice = async () => {
40
+ if (setupNoticeShown) {
41
+ return false;
42
+ }
43
+ const pwvmDir = getPwvmDir();
44
+ const shimsDir = getShimsDir();
45
+ const setupMarker = path.join(pwvmDir, "setup-complete");
46
+ let markerExists = false;
47
+ let shimsDirExists = false;
48
+ try {
49
+ markerExists = await fs.pathExists(setupMarker);
50
+ }
51
+ catch {
52
+ markerExists = false;
53
+ }
54
+ try {
55
+ shimsDirExists = await fs.pathExists(shimsDir);
56
+ }
57
+ catch {
58
+ shimsDirExists = false;
59
+ }
60
+ const pathEntries = (process.env.PATH ?? "")
61
+ .split(path.delimiter)
62
+ .filter(Boolean);
63
+ const pathHasShims = pathEntries.includes(shimsDir);
64
+ return !(markerExists || (shimsDirExists && pathHasShims));
65
+ };
66
+ program.hook("preAction", async (thisCommand, actionCommand) => {
67
+ if (!actionCommand) {
68
+ return;
69
+ }
70
+ if (actionCommand.parent && actionCommand.parent !== thisCommand) {
71
+ return;
72
+ }
73
+ const commandName = actionCommand.name();
74
+ if (commandName === "_shim" || commandName === "setup") {
75
+ return;
76
+ }
77
+ const shouldWarn = await shouldShowSetupNotice();
78
+ if (!shouldWarn) {
79
+ return;
80
+ }
81
+ setupNoticeShown = true;
82
+ logger.warn("pwvm is not set up yet.\n");
83
+ logger.info("To finish setup, run:");
84
+ logger.info("pwvm setup");
85
+ logger.info("This will configure Playwright shims and print the PATH entry you need to add.");
86
+ });
87
+ program
88
+ .command("current")
89
+ .description("Show active Playwright version")
90
+ .action(() => invoke(runCurrentCommand));
91
+ program
92
+ .command("doctor")
93
+ .description("Check pwvm setup status")
94
+ .action(() => invoke(runDoctorCommand));
95
+ program
96
+ .command("install")
97
+ .description("Install a Playwright version")
98
+ .argument("<version>", "Playwright version to install")
99
+ .option("--with-browsers", "Install Playwright browsers (default)")
100
+ .option("--no-browsers", "Skip Playwright browser install")
101
+ .action((version, options) => invoke(runInstallCommand, version, options));
102
+ program
103
+ .command("list")
104
+ .description("List installed Playwright versions")
105
+ .action(() => invoke(runListCommand));
106
+ program
107
+ .command("list-remote")
108
+ .description("List available Playwright versions")
109
+ .action(() => invoke(runListRemoteCommand));
110
+ program
111
+ .command("prune")
112
+ .description("Remove unused Playwright versions")
113
+ .action(() => invoke(runPruneCommand));
114
+ program
115
+ .command("setup")
116
+ .description("Create shims and show PATH instructions")
117
+ .action(() => invoke(runSetupCommand));
118
+ program
119
+ .command("use")
120
+ .description("Set active Playwright version")
121
+ .argument("<version>", "Playwright version to use")
122
+ .action((version) => invoke(runUseCommand, version));
123
+ program
124
+ .command("_shim", { hidden: true })
125
+ .argument("[args...]", "Arguments passed through to Playwright")
126
+ .allowUnknownOption(true)
127
+ .allowExcessArguments(true)
128
+ .passThroughOptions()
129
+ .action(async () => {
130
+ try {
131
+ await runPlaywrightShim(getShimArgs());
132
+ }
133
+ catch (error) {
134
+ const message = error instanceof Error ? error.message : String(error);
135
+ const shimError = getShimErrorDetails(message);
136
+ if (!shimError) {
137
+ throw error;
138
+ }
139
+ logger.error(shimError.message);
140
+ if (shimError.hint) {
141
+ logger.info(shimError.hint);
142
+ }
143
+ process.exitCode = 1;
144
+ }
145
+ });
146
+ void program.parseAsync();
147
+ function getShimErrorDetails(message) {
148
+ const guidanceMarker = ". Run ";
149
+ const guidanceIndex = message.indexOf(guidanceMarker);
150
+ if (guidanceIndex !== -1) {
151
+ const base = message.slice(0, guidanceIndex + 1);
152
+ const hint = message.slice(guidanceIndex + 2);
153
+ return { message: base, hint };
154
+ }
155
+ const notInstalled = /^Playwright (.+) is not installed\.$/.exec(message);
156
+ if (notInstalled) {
157
+ const version = notInstalled[1];
158
+ return {
159
+ message,
160
+ hint: `Run \`pwvm install ${version}\` first.`,
161
+ };
162
+ }
163
+ return null;
164
+ }
@@ -0,0 +1,104 @@
1
+ import path from "node:path";
2
+ import { afterEach, describe, expect, it, vi } from "vitest";
3
+ vi.mock("fs-extra", () => ({
4
+ default: {
5
+ pathExists: vi.fn(),
6
+ },
7
+ }));
8
+ vi.mock("./core/paths.js", () => ({
9
+ getPwvmDir: vi.fn(),
10
+ getShimsDir: vi.fn(),
11
+ }));
12
+ vi.mock("./commands/list.js", () => ({
13
+ runListCommand: vi.fn(),
14
+ }));
15
+ describe("setup reminder", () => {
16
+ const originalArgv = process.argv.slice();
17
+ const originalPath = process.env.PATH;
18
+ const originalExitCode = process.exitCode;
19
+ const originalIsTTY = process.stdout.isTTY;
20
+ afterEach(() => {
21
+ process.argv = originalArgv.slice();
22
+ process.env.PATH = originalPath;
23
+ process.exitCode = originalExitCode;
24
+ Object.defineProperty(process.stdout, "isTTY", {
25
+ value: originalIsTTY,
26
+ configurable: true,
27
+ });
28
+ vi.restoreAllMocks();
29
+ });
30
+ it("shows reminder when setup is incomplete and still runs the command", async () => {
31
+ vi.resetModules();
32
+ Object.defineProperty(process.stdout, "isTTY", {
33
+ value: false,
34
+ configurable: true,
35
+ });
36
+ const fsExtra = await import("fs-extra");
37
+ const paths = await import("./core/paths.js");
38
+ const listModule = await import("./commands/list.js");
39
+ const pathExistsMock = vi.mocked(fsExtra.default.pathExists);
40
+ const getPwvmDirMock = vi.mocked(paths.getPwvmDir);
41
+ const getShimsDirMock = vi.mocked(paths.getShimsDir);
42
+ const runListCommandMock = vi.mocked(listModule.runListCommand);
43
+ const pwvmDir = "/mock/pwvm";
44
+ const shimsDir = "/mock/pwvm/shims";
45
+ getPwvmDirMock.mockReturnValue(pwvmDir);
46
+ getShimsDirMock.mockReturnValue(shimsDir);
47
+ pathExistsMock.mockResolvedValue(false);
48
+ process.env.PATH = "/usr/bin";
49
+ process.argv = ["node", "pwvm", "list"];
50
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => { });
51
+ vi.spyOn(console, "log").mockImplementation(() => { });
52
+ let resolveList = null;
53
+ const listDone = new Promise((resolve) => {
54
+ resolveList = resolve;
55
+ });
56
+ runListCommandMock.mockImplementation(async () => {
57
+ resolveList?.();
58
+ });
59
+ await import("./index.js");
60
+ await listDone;
61
+ expect(runListCommandMock).toHaveBeenCalledTimes(1);
62
+ expect(warnSpy).toHaveBeenCalled();
63
+ const warning = String(warnSpy.mock.calls[0][0]);
64
+ expect(warning).toContain("[WARN]:");
65
+ expect(warning).toContain("pwvm is not set up yet.");
66
+ });
67
+ it("does not show reminder when setup is complete", async () => {
68
+ vi.resetModules();
69
+ Object.defineProperty(process.stdout, "isTTY", {
70
+ value: false,
71
+ configurable: true,
72
+ });
73
+ const fsExtra = await import("fs-extra");
74
+ const paths = await import("./core/paths.js");
75
+ const listModule = await import("./commands/list.js");
76
+ const pathExistsMock = vi.mocked(fsExtra.default.pathExists);
77
+ const getPwvmDirMock = vi.mocked(paths.getPwvmDir);
78
+ const getShimsDirMock = vi.mocked(paths.getShimsDir);
79
+ const runListCommandMock = vi.mocked(listModule.runListCommand);
80
+ const pwvmDir = "/mock/pwvm";
81
+ const shimsDir = "/mock/pwvm/shims";
82
+ const markerPath = path.join(pwvmDir, "setup-complete");
83
+ getPwvmDirMock.mockReturnValue(pwvmDir);
84
+ getShimsDirMock.mockReturnValue(shimsDir);
85
+ pathExistsMock.mockImplementation(async (target) => {
86
+ const value = String(target);
87
+ return value === markerPath;
88
+ });
89
+ process.env.PATH = "/usr/bin";
90
+ process.argv = ["node", "pwvm", "list"];
91
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => { });
92
+ let resolveList = null;
93
+ const listDone = new Promise((resolve) => {
94
+ resolveList = resolve;
95
+ });
96
+ runListCommandMock.mockImplementation(async () => {
97
+ resolveList?.();
98
+ });
99
+ await import("./index.js");
100
+ await listDone;
101
+ expect(runListCommandMock).toHaveBeenCalledTimes(1);
102
+ expect(warnSpy).not.toHaveBeenCalled();
103
+ });
104
+ });
@@ -0,0 +1,46 @@
1
+ import fs from "fs-extra";
2
+ import { mkdtemp, rm } from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ export const createTempPwvmHome = async () => {
6
+ const prefix = path.join(os.tmpdir(), "pwvm-");
7
+ const homeDir = await mkdtemp(prefix);
8
+ const cleanup = async () => {
9
+ await rm(homeDir, { recursive: true, force: true });
10
+ };
11
+ return { homeDir, cleanup };
12
+ };
13
+ const normalizePath = (value) => typeof value === "string" ? value : value.toString();
14
+ export const createFsMock = (rootDir) => {
15
+ const base = path.resolve(rootDir);
16
+ const resolveWithinRoot = (target) => {
17
+ const targetPath = normalizePath(target);
18
+ const absolute = path.isAbsolute(targetPath)
19
+ ? targetPath
20
+ : path.join(base, targetPath);
21
+ const relative = path.relative(base, absolute);
22
+ if (relative.startsWith("..") || path.isAbsolute(relative)) {
23
+ throw new Error(`Access outside mock root: ${targetPath}`);
24
+ }
25
+ return absolute;
26
+ };
27
+ return {
28
+ pathExists: (target) => fs.pathExists(resolveWithinRoot(target)),
29
+ readdir: (target, options) => {
30
+ const resolved = options ?? { withFileTypes: true };
31
+ return fs.readdir(resolveWithinRoot(target), resolved);
32
+ },
33
+ readFile: (target, options) => {
34
+ const resolved = options ?? "utf8";
35
+ return fs.readFile(resolveWithinRoot(target), resolved);
36
+ },
37
+ writeFile: (target, data, options) => {
38
+ const resolved = options ?? "utf8";
39
+ return fs.writeFile(resolveWithinRoot(target), data, resolved);
40
+ },
41
+ ensureDir: (target) => fs.ensureDir(resolveWithinRoot(target)),
42
+ remove: (target) => fs.remove(resolveWithinRoot(target)),
43
+ existsSync: (target) => fs.existsSync(resolveWithinRoot(target)),
44
+ statSync: (target) => fs.statSync(resolveWithinRoot(target)),
45
+ };
46
+ };
@@ -0,0 +1,34 @@
1
+ import { mkdtemp, rm } from "node:fs/promises";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { createPaths } from "../core/paths.js";
5
+ export const createTestPwvmEnv = async () => {
6
+ const baseDir = await mkdtemp(path.join(os.tmpdir(), "pwvm-test-"));
7
+ const homeDir = baseDir;
8
+ const paths = createPaths(homeDir);
9
+ const options = {
10
+ homeDir,
11
+ pwvmDir: paths.pwvmDir,
12
+ versionsDir: paths.versionsDir,
13
+ shimsDir: paths.shimsDir,
14
+ activeVersionFile: paths.activeVersionFile,
15
+ };
16
+ const cleanup = async () => {
17
+ await rm(baseDir, { recursive: true, force: true });
18
+ };
19
+ return {
20
+ homeDir,
21
+ paths,
22
+ options,
23
+ cleanup,
24
+ };
25
+ };
26
+ export const withTestPwvmEnv = async (fn) => {
27
+ const env = await createTestPwvmEnv();
28
+ try {
29
+ return await fn(env);
30
+ }
31
+ finally {
32
+ await env.cleanup();
33
+ }
34
+ };
@@ -0,0 +1,64 @@
1
+ const defaultLogger = {
2
+ info: (message) => console.log(message),
3
+ warn: (message) => console.warn(message),
4
+ error: (message) => console.error(message),
5
+ };
6
+ const isDoctorLine = (message) => message.startsWith("info pwvm-doctor") || message.startsWith("WARN pwvm-doctor");
7
+ const ANSI = {
8
+ green: "\x1b[32m",
9
+ yellow: "\x1b[33m",
10
+ red: "\x1b[31m",
11
+ reset: "\x1b[0m",
12
+ };
13
+ const supportsColor = () => process.stdout.isTTY === true;
14
+ const prefixInfo = (message) => {
15
+ if (isDoctorLine(message)) {
16
+ return message;
17
+ }
18
+ const base = message.startsWith("[INFO]:")
19
+ ? message.slice("[INFO]:".length).trimStart()
20
+ : message;
21
+ const prefix = supportsColor()
22
+ ? `${ANSI.green}[INFO]:${ANSI.reset}`
23
+ : "[INFO]:";
24
+ return `${prefix} ${base}`;
25
+ };
26
+ const prefixWarn = (message) => {
27
+ if (isDoctorLine(message)) {
28
+ return message;
29
+ }
30
+ const base = message.startsWith("[WARN]:")
31
+ ? message.slice("[WARN]:".length).trimStart()
32
+ : message;
33
+ const prefix = supportsColor()
34
+ ? `${ANSI.yellow}[WARN]:${ANSI.reset}`
35
+ : "[WARN]:";
36
+ return `${prefix} ${base}`;
37
+ };
38
+ const prefixError = (message) => {
39
+ const trimmed = message.startsWith("[ERROR]:")
40
+ ? message.slice("[ERROR]:".length).trimStart()
41
+ : message.startsWith("Error:")
42
+ ? message.slice("Error:".length).trimStart()
43
+ : message;
44
+ const prefix = supportsColor()
45
+ ? `${ANSI.red}[ERROR]:${ANSI.reset}`
46
+ : "[ERROR]:";
47
+ return `${prefix} ${trimmed}`;
48
+ };
49
+ export const createLogger = (overrides = {}) => ({
50
+ info: overrides.info ?? ((message) => defaultLogger.info(prefixInfo(message))),
51
+ warn: overrides.warn ?? ((message) => defaultLogger.warn(prefixWarn(message))),
52
+ error: overrides.error ?? ((message) => defaultLogger.error(prefixError(message))),
53
+ });
54
+ const toSingleLine = (message) => message.replace(/\s+/g, " ").trim();
55
+ export const formatError = (message, hint) => {
56
+ const base = toSingleLine(message);
57
+ const withPrefix = base.startsWith("Error:") ? base : `Error: ${base}`;
58
+ if (!hint) {
59
+ return withPrefix;
60
+ }
61
+ return `${withPrefix} ${toSingleLine(hint)}`;
62
+ };
63
+ const normalizeDoctorMessage = (message) => message.replace(/\s*\n\s*/g, " ").replace(/\s+$/g, "");
64
+ export const formatDoctorLine = (level, message) => `${level} pwvm-doctor ${normalizeDoctorMessage(message)}`;
@@ -0,0 +1,87 @@
1
+ export const createProgress = (options) => {
2
+ const stream = options.stream ?? process.stdout;
3
+ const isTTY = stream.isTTY === true;
4
+ const intervalMs = options.intervalMs ?? 100;
5
+ const label = options.label;
6
+ const logger = options.logger;
7
+ const width = 24;
8
+ const gradient = ["\x1b[34m", "\x1b[94m", "\x1b[97m", "\x1b[94m", "\x1b[34m"];
9
+ const reset = "\x1b[0m";
10
+ let progress = 0;
11
+ let timer = null;
12
+ let lastVisibleLength = 0;
13
+ let phase = 0;
14
+ let started = false;
15
+ const render = () => {
16
+ const filled = Math.min(width, Math.round((progress / 100) * width));
17
+ let bar = "";
18
+ for (let i = 0; i < filled; i += 1) {
19
+ const color = gradient[(i + phase) % gradient.length];
20
+ bar += `${color}█${reset}`;
21
+ }
22
+ bar += "░".repeat(width - filled);
23
+ const percentText = `${progress.toFixed(0)}%`;
24
+ const line = `${label} [${bar}] ${percentText}`;
25
+ const visibleLength = label.length + 3 + width + 2 + percentText.length;
26
+ stream.write(`\r${line}${" ".repeat(Math.max(0, lastVisibleLength - visibleLength))}`);
27
+ lastVisibleLength = visibleLength;
28
+ };
29
+ const clear = () => {
30
+ if (lastVisibleLength === 0) {
31
+ return;
32
+ }
33
+ stream.write(`\r${" ".repeat(lastVisibleLength)}\r`);
34
+ lastVisibleLength = 0;
35
+ };
36
+ return {
37
+ start: () => {
38
+ if (started) {
39
+ return;
40
+ }
41
+ started = true;
42
+ progress = 0;
43
+ if (!isTTY) {
44
+ logger?.info(`${label}... (in progress)`);
45
+ return;
46
+ }
47
+ render();
48
+ timer = setInterval(() => {
49
+ if (progress < 95) {
50
+ progress += 1;
51
+ phase = (phase + 1) % gradient.length;
52
+ render();
53
+ }
54
+ }, intervalMs);
55
+ },
56
+ succeed: () => {
57
+ if (!started) {
58
+ return;
59
+ }
60
+ if (timer) {
61
+ clearInterval(timer);
62
+ timer = null;
63
+ }
64
+ if (!isTTY) {
65
+ logger?.info(`${label}... done`);
66
+ return;
67
+ }
68
+ progress = 100;
69
+ phase = 0;
70
+ render();
71
+ stream.write("\n");
72
+ lastVisibleLength = 0;
73
+ },
74
+ fail: () => {
75
+ if (!started) {
76
+ return;
77
+ }
78
+ if (timer) {
79
+ clearInterval(timer);
80
+ timer = null;
81
+ }
82
+ if (isTTY) {
83
+ clear();
84
+ }
85
+ },
86
+ };
87
+ };
@@ -0,0 +1,36 @@
1
+ import https from "node:https";
2
+ const REGISTRY_URL = "https://registry.npmjs.org";
3
+ export const fetchPackageMetadata = async (packageName) => new Promise((resolve, reject) => {
4
+ const url = `${REGISTRY_URL}/${encodeURIComponent(packageName)}`;
5
+ const request = https.get(url, {
6
+ headers: {
7
+ Accept: "application/vnd.npm.install-v1+json",
8
+ },
9
+ }, (response) => {
10
+ if (!response.statusCode || response.statusCode >= 400) {
11
+ reject(new Error("Registry request failed."));
12
+ response.resume();
13
+ return;
14
+ }
15
+ response.setEncoding("utf8");
16
+ let body = "";
17
+ response.on("data", (chunk) => {
18
+ body += chunk;
19
+ });
20
+ response.on("end", () => {
21
+ try {
22
+ const parsed = JSON.parse(body);
23
+ resolve(parsed);
24
+ }
25
+ catch {
26
+ reject(new Error("Invalid registry response."));
27
+ }
28
+ });
29
+ });
30
+ request.on("error", (error) => {
31
+ reject(error);
32
+ });
33
+ request.setTimeout(8000, () => {
34
+ request.destroy(new Error("Registry request timed out."));
35
+ });
36
+ });
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "private": false,
3
+ "name": "pwvm",
4
+ "version": "0.1.0",
5
+ "description": "Playwright Version Manager",
6
+ "license": "MIT",
7
+ "type": "module",
8
+ "author": "Millan Kaul",
9
+ "bin": {
10
+ "pwvm": "dist/index.js"
11
+ },
12
+ "files": [
13
+ "dist",
14
+ "README.md",
15
+ "LICENSE"
16
+ ],
17
+ "scripts": {
18
+ "build": "tsc -p tsconfig.build.json",
19
+ "dev": "ts-node src/index.ts",
20
+ "test": "vitest",
21
+ "test:e2e": "vitest -c itest.config.ts"
22
+ },
23
+ "keywords": [
24
+ "playwright",
25
+ "playwright-version-manager",
26
+ "playwright-cli",
27
+ "version-manager",
28
+ "testing",
29
+ "e2e"
30
+ ],
31
+ "engines": {
32
+ "node": ">=18"
33
+ },
34
+ "repository": {
35
+ "type": "git",
36
+ "url": "git+https://github.com/eaccmk/pwvm.git"
37
+ },
38
+ "dependencies": {
39
+ "commander": "^14.0.2",
40
+ "execa": "^9.6.1",
41
+ "fs-extra": "^11.3.3",
42
+ "semver": "^7.7.3"
43
+ },
44
+ "devDependencies": {
45
+ "@types/fs-extra": "^11.0.4",
46
+ "@types/node": "^25.0.3",
47
+ "@types/semver": "^7.7.1",
48
+ "ts-node": "^10.9.2",
49
+ "typescript": "^5.9.3",
50
+ "vitest": "^4.0.16"
51
+ }
52
+ }