hauntview 0.0.0-stage → 0.0.6
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/LICENSE +21 -0
- package/README.md +358 -2
- package/VERSION +1 -0
- package/dist/cli.js +8184 -0
- package/package.json +120 -4
- package/scripts/postinstall.mjs +47 -0
- package/src/core/browser-launch.ts +60 -0
- package/src/core/browser-pool.ts +267 -0
- package/src/core/concurrency-resolver.ts +154 -0
- package/src/core/finding-classifier.ts +346 -0
- package/src/core/index.ts +8 -0
- package/src/core/profile-manager.ts +499 -0
- package/src/core/report-store.ts +117 -0
- package/src/core/screenshot-manager.ts +312 -0
- package/src/core/system-capability.ts +161 -0
- package/src/core/visual-diff.ts +230 -0
- package/src/engine.ts +473 -0
- package/src/profiles/accessibility-cognitive.ts +261 -0
- package/src/profiles/accessibility-keyboard.ts +225 -0
- package/src/profiles/accessibility-screenreader.ts +243 -0
- package/src/profiles/accessibility-visual.ts +257 -0
- package/src/profiles/broken-links.ts +261 -0
- package/src/profiles/browser-edge.ts +192 -0
- package/src/profiles/browser-firefox.ts +254 -0
- package/src/profiles/browser-legacy.ts +213 -0
- package/src/profiles/console-errors.ts +192 -0
- package/src/profiles/desktop-4k.ts +273 -0
- package/src/profiles/desktop.ts +192 -0
- package/src/profiles/font-failure.ts +148 -0
- package/src/profiles/form-resilience.ts +285 -0
- package/src/profiles/i18n-cjk.ts +218 -0
- package/src/profiles/i18n-mixed.ts +255 -0
- package/src/profiles/i18n-rtl.ts +236 -0
- package/src/profiles/image-failure.ts +178 -0
- package/src/profiles/index.ts +42 -0
- package/src/profiles/js-disabled.ts +179 -0
- package/src/profiles/mobile.ts +175 -0
- package/src/profiles/network-resilience.ts +208 -0
- package/src/profiles/offline.ts +165 -0
- package/src/profiles/performance.ts +244 -0
- package/src/profiles/reduced-motion.ts +144 -0
- package/src/profiles/security-hygiene.ts +206 -0
- package/src/profiles/tablet.ts +176 -0
- package/src/reports/factory.ts +56 -0
- package/src/reports/html-generator.ts +1105 -0
- package/src/reports/index.ts +9 -0
- package/src/reports/markdown-generator.ts +349 -0
- package/src/reports/types.ts +34 -0
- package/src/reports/zip-generator.ts +85 -0
- package/src/types/contracts.ts +238 -0
- package/src/types/finding-utils.ts +121 -0
- package/src/types/index.ts +8 -0
- package/src/types/profiles.ts +175 -0
- package/src/types/results.ts +135 -0
- package/src/version.ts +5 -0
package/package.json
CHANGED
|
@@ -1,6 +1,122 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hauntview",
|
|
3
|
-
"version": "0.0.
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
6
|
-
|
|
3
|
+
"version": "0.0.6",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Local-first website resilience, accessibility, failure-mode and visual regression testing with Playwright and MCP.",
|
|
6
|
+
"bin": {
|
|
7
|
+
"haunt": "dist/cli.js",
|
|
8
|
+
"hauntview": "dist/cli.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"dist",
|
|
12
|
+
"src/core",
|
|
13
|
+
"!src/core/__tests__",
|
|
14
|
+
"src/engine.ts",
|
|
15
|
+
"src/profiles",
|
|
16
|
+
"!src/profiles/__tests__",
|
|
17
|
+
"src/reports",
|
|
18
|
+
"src/types",
|
|
19
|
+
"src/version.ts",
|
|
20
|
+
"scripts/postinstall.mjs",
|
|
21
|
+
"VERSION",
|
|
22
|
+
"README.md",
|
|
23
|
+
"LICENSE"
|
|
24
|
+
],
|
|
25
|
+
"engines": {
|
|
26
|
+
"node": ">=22.12.0",
|
|
27
|
+
"npm": ">=10.0.0"
|
|
28
|
+
},
|
|
29
|
+
"scripts": {
|
|
30
|
+
"dev": "node init && next dev --hostname 127.0.0.1",
|
|
31
|
+
"postinstall": "node scripts/postinstall.mjs",
|
|
32
|
+
"build:cli": "node scripts/build-cli.mjs",
|
|
33
|
+
"build": "node init && node scripts/build-cli.mjs && next build",
|
|
34
|
+
"start": "node init && next start --hostname 0.0.0.0",
|
|
35
|
+
"lint": "eslint .",
|
|
36
|
+
"lint:fix": "eslint . --fix",
|
|
37
|
+
"typecheck": "tsc --noEmit",
|
|
38
|
+
"format": "prettier --write .",
|
|
39
|
+
"format:check": "prettier --check .",
|
|
40
|
+
"check": "pnpm run typecheck && pnpm run lint && pnpm run format:check",
|
|
41
|
+
"test": "pnpm run test:unit",
|
|
42
|
+
"test:unit": "jest --runInBand",
|
|
43
|
+
"test:watch": "jest --watch",
|
|
44
|
+
"test:coverage": "jest --coverage --runInBand",
|
|
45
|
+
"test:all": "pnpm run test:unit && pnpm run test:e2e",
|
|
46
|
+
"test:e2e": "playwright test",
|
|
47
|
+
"test:e2e:ui": "playwright test --ui",
|
|
48
|
+
"test:e2e:headed": "playwright test --headed",
|
|
49
|
+
"test:report": "playwright show-report",
|
|
50
|
+
"browsers:install": "playwright install chromium firefox webkit",
|
|
51
|
+
"mcp": "tsx src/mcp/server.ts",
|
|
52
|
+
"presubmit": "node scripts/presubmit.mjs",
|
|
53
|
+
"version:bump": "node scripts/bump-version.mjs",
|
|
54
|
+
"version:check": "node scripts/check-version.mjs",
|
|
55
|
+
"setup:hooks": "husky",
|
|
56
|
+
"prepare": "husky"
|
|
57
|
+
},
|
|
58
|
+
"lint-staged": {
|
|
59
|
+
"*.{ts,tsx,js,jsx,mjs,cjs}": [
|
|
60
|
+
"eslint --fix",
|
|
61
|
+
"prettier --write",
|
|
62
|
+
"eslint"
|
|
63
|
+
],
|
|
64
|
+
"*.{json,md,mdx,css,yml,yaml}": [
|
|
65
|
+
"prettier --write"
|
|
66
|
+
]
|
|
67
|
+
},
|
|
68
|
+
"dependencies": {
|
|
69
|
+
"@axe-core/playwright": "^4.14.0",
|
|
70
|
+
"@heroicons/react": "^2.2.0",
|
|
71
|
+
"@inquirer/prompts": "^8.7.5",
|
|
72
|
+
"@modelcontextprotocol/server": "^2.4.0",
|
|
73
|
+
"@sparticuz/chromium": "^153.0.0",
|
|
74
|
+
"@types/jszip": "^3.4.1",
|
|
75
|
+
"better-sqlite3": "^13.0.3",
|
|
76
|
+
"class-variance-authority": "^0.7.1",
|
|
77
|
+
"clsx": "^2.1.1",
|
|
78
|
+
"commander": "^15.0.0",
|
|
79
|
+
"ipaddr.js": "^2.5.0",
|
|
80
|
+
"jszip": "^3.10.2",
|
|
81
|
+
"lucide-react": "^1.69.0",
|
|
82
|
+
"next": "16.4.3",
|
|
83
|
+
"nextjs-toploader": "^3.9.17",
|
|
84
|
+
"p-limit": "^7.3.3",
|
|
85
|
+
"pixelmatch": "^8.0.0",
|
|
86
|
+
"playwright": "^1.65.0",
|
|
87
|
+
"pngjs": "^7.0.0",
|
|
88
|
+
"react": "19.3.1",
|
|
89
|
+
"react-dom": "19.3.1",
|
|
90
|
+
"sonner": "^2.0.8",
|
|
91
|
+
"tailwind-merge": "^3.7.0",
|
|
92
|
+
"zod": "^4.6.7",
|
|
93
|
+
"zustand": "^5.0.16"
|
|
94
|
+
},
|
|
95
|
+
"devDependencies": {
|
|
96
|
+
"@jest/globals": "^29.7.0",
|
|
97
|
+
"@playwright/test": "^1.65.0",
|
|
98
|
+
"@tailwindcss/postcss": "^4.3.4",
|
|
99
|
+
"@types/better-sqlite3": "^9.6.0",
|
|
100
|
+
"@types/jest": "^29.5.14",
|
|
101
|
+
"@types/node": "^24.19.2",
|
|
102
|
+
"@types/pngjs": "^6.0.5",
|
|
103
|
+
"@types/react": "^19.3.0",
|
|
104
|
+
"@types/react-dom": "^19.3.0",
|
|
105
|
+
"esbuild": "^0.28.2",
|
|
106
|
+
"eslint": "^10.14.0",
|
|
107
|
+
"eslint-config-next": "16.4.3",
|
|
108
|
+
"eslint-config-prettier": "^10.1.8",
|
|
109
|
+
"fast-check": "^4.10.2",
|
|
110
|
+
"husky": "^9.1.7",
|
|
111
|
+
"jest": "^29.7.0",
|
|
112
|
+
"jest-environment-node": "^29.7.0",
|
|
113
|
+
"lint-staged": "^17.6.0",
|
|
114
|
+
"postcss": "^8.5.32",
|
|
115
|
+
"prettier": "^3.9.10",
|
|
116
|
+
"prettier-plugin-tailwindcss": "^0.6.14",
|
|
117
|
+
"tailwindcss": "^4.3.4",
|
|
118
|
+
"ts-jest": "^29.4.15",
|
|
119
|
+
"tsx": "^4.23.15",
|
|
120
|
+
"typescript": "^5.9.3"
|
|
121
|
+
}
|
|
122
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* postinstall.mjs
|
|
4
|
+
*
|
|
5
|
+
* Installs Playwright's Chromium browser in local development only,
|
|
6
|
+
* and only when it isn't already installed for the current Playwright version.
|
|
7
|
+
*
|
|
8
|
+
* Skip conditions:
|
|
9
|
+
* - RENDER=true → Render build/runtime (uses @sparticuz/chromium instead)
|
|
10
|
+
* - CI=true → GitHub Actions and most hosted CI (no browsers needed for lint/build)
|
|
11
|
+
* - SKIP_PLAYWRIGHT_INSTALL=1 → manual opt-out
|
|
12
|
+
*
|
|
13
|
+
* Idempotency:
|
|
14
|
+
* We ask `playwright install --dry-run chromium` first. If it exits 0 with
|
|
15
|
+
* no output (nothing to install), we skip. This avoids re-downloading on
|
|
16
|
+
* every `pnpm install` when the binary is already cached.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { execSync, spawnSync } from 'node:child_process';
|
|
20
|
+
|
|
21
|
+
const isRender = process.env.RENDER === 'true';
|
|
22
|
+
const isCI = process.env.CI === 'true';
|
|
23
|
+
const isSkipped = process.env.SKIP_PLAYWRIGHT_INSTALL === '1';
|
|
24
|
+
|
|
25
|
+
if (isRender || isCI || isSkipped) {
|
|
26
|
+
const reason = isRender ? 'RENDER=true' : isCI ? 'CI=true' : 'SKIP_PLAYWRIGHT_INSTALL=1';
|
|
27
|
+
console.log(`[postinstall] Skipping Playwright install (${reason}).`);
|
|
28
|
+
process.exit(0);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Check if Chromium is already installed for this exact Playwright version.
|
|
32
|
+
// `playwright install --dry-run` prints what *would* be downloaded.
|
|
33
|
+
// If stdout is empty (or only whitespace), everything is already cached.
|
|
34
|
+
const dryRun = spawnSync('pnpm', ['exec', 'playwright', 'install', '--dry-run', 'chromium'], {
|
|
35
|
+
encoding: 'utf8',
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const dryRunOutput = (dryRun.stdout ?? '').trim();
|
|
39
|
+
const alreadyInstalled = dryRun.status === 0 && dryRunOutput === '';
|
|
40
|
+
|
|
41
|
+
if (alreadyInstalled) {
|
|
42
|
+
console.log('[postinstall] Playwright Chromium already installed — nothing to do.');
|
|
43
|
+
process.exit(0);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
console.log('[postinstall] Installing Playwright Chromium...');
|
|
47
|
+
execSync('pnpm exec playwright install chromium', { stdio: 'inherit' });
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* browser-launch.ts
|
|
3
|
+
*
|
|
4
|
+
* Resolves the correct Chromium launch options for the current environment.
|
|
5
|
+
*
|
|
6
|
+
* - Production / Render: uses @sparticuz/chromium, which ships its own binary
|
|
7
|
+
* compatible with serverless/constrained Linux environments (0.5 CPU, 512MB RAM).
|
|
8
|
+
* - Local development: returns undefined executablePath so Playwright uses its own bundled binary.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export interface BrowserLaunchConfig {
|
|
12
|
+
executablePath?: string;
|
|
13
|
+
args: string[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Returns Chromium launch options (executablePath + required flags) for the current environment.
|
|
18
|
+
*/
|
|
19
|
+
export async function getChromiumLaunchOptions(): Promise<BrowserLaunchConfig> {
|
|
20
|
+
const isRender = process.env.RENDER === 'true';
|
|
21
|
+
const isCI = process.env.CI === 'true';
|
|
22
|
+
|
|
23
|
+
if (isRender || isCI) {
|
|
24
|
+
// Dynamically import to avoid loading the binary in dev where it's unneeded.
|
|
25
|
+
const { default: sparticuzChromium } = await import('@sparticuz/chromium');
|
|
26
|
+
|
|
27
|
+
sparticuzChromium.setGraphicsMode = false;
|
|
28
|
+
|
|
29
|
+
const executablePath = await sparticuzChromium.executablePath();
|
|
30
|
+
const args = [
|
|
31
|
+
...sparticuzChromium.args,
|
|
32
|
+
'--headless=new',
|
|
33
|
+
'--no-sandbox',
|
|
34
|
+
'--disable-setuid-sandbox',
|
|
35
|
+
'--disable-dev-shm-usage',
|
|
36
|
+
'--disable-gpu',
|
|
37
|
+
'--single-process',
|
|
38
|
+
'--no-zygote',
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
executablePath,
|
|
43
|
+
args,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Local development: standard Playwright flags
|
|
48
|
+
return {
|
|
49
|
+
executablePath: undefined,
|
|
50
|
+
args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage'],
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Backward-compatible helper returning just the executable path.
|
|
56
|
+
*/
|
|
57
|
+
export async function getChromiumExecutablePath(): Promise<string | undefined> {
|
|
58
|
+
const config = await getChromiumLaunchOptions();
|
|
59
|
+
return config.executablePath;
|
|
60
|
+
}
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BrowserPool - Manages Playwright browser instances and page lifecycle
|
|
3
|
+
* Handles browser instance pooling, page reuse, and resource cleanup
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { Browser, BrowserContext, Page } from 'playwright';
|
|
7
|
+
import { BrowserPoolConfig, ProfileContext } from '../types';
|
|
8
|
+
|
|
9
|
+
interface PooledBrowser {
|
|
10
|
+
browser: Browser;
|
|
11
|
+
pages: Page[];
|
|
12
|
+
activePages: Set<Page>;
|
|
13
|
+
lastUsed: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface PooledPage {
|
|
17
|
+
page: Page;
|
|
18
|
+
context: BrowserContext;
|
|
19
|
+
profileContext?: ProfileContext;
|
|
20
|
+
inUse: boolean;
|
|
21
|
+
lastUsed?: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export class BrowserPool {
|
|
25
|
+
private browsers: Map<string, PooledBrowser> = new Map();
|
|
26
|
+
private pagePool: PooledPage[] = [];
|
|
27
|
+
private config: BrowserPoolConfig;
|
|
28
|
+
private cleanupInterval: NodeJS.Timeout | null = null;
|
|
29
|
+
|
|
30
|
+
constructor(config: BrowserPoolConfig) {
|
|
31
|
+
this.config = {
|
|
32
|
+
maxBrowsers: config.maxBrowsers ?? 3,
|
|
33
|
+
maxPagesPerBrowser: config.maxPagesPerBrowser ?? 5,
|
|
34
|
+
idleTimeout: config.idleTimeout ?? 30000,
|
|
35
|
+
launchOptions: config.launchOptions,
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
// Start cleanup interval
|
|
39
|
+
this.startCleanupInterval();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Acquire a page from the pool or create a new one
|
|
44
|
+
*/
|
|
45
|
+
async acquirePage(
|
|
46
|
+
browserKey: string,
|
|
47
|
+
profileContext: ProfileContext,
|
|
48
|
+
playwrightBrowser: Browser
|
|
49
|
+
): Promise<Page> {
|
|
50
|
+
let pooledBrowser = this.browsers.get(browserKey);
|
|
51
|
+
if (!pooledBrowser) {
|
|
52
|
+
this.registerBrowser(browserKey, playwrightBrowser);
|
|
53
|
+
pooledBrowser = this.browsers.get(browserKey)!;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Clean up closed pages
|
|
57
|
+
pooledBrowser.pages = pooledBrowser.pages.filter((p) => !p.isClosed());
|
|
58
|
+
for (const p of pooledBrowser.activePages) {
|
|
59
|
+
if (p.isClosed()) {
|
|
60
|
+
pooledBrowser.activePages.delete(p);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// If active pages count exceeds limit, clean up idle pages
|
|
65
|
+
if (pooledBrowser.pages.length >= this.config.maxPagesPerBrowser) {
|
|
66
|
+
const idlePages = this.pagePool.filter((p) => !p.inUse);
|
|
67
|
+
for (const idle of idlePages) {
|
|
68
|
+
try {
|
|
69
|
+
if (!idle.page.isClosed()) {
|
|
70
|
+
await idle.page.close();
|
|
71
|
+
}
|
|
72
|
+
await idle.context.close();
|
|
73
|
+
} catch {
|
|
74
|
+
// Already closed
|
|
75
|
+
}
|
|
76
|
+
this.pagePool = this.pagePool.filter((p) => p !== idle);
|
|
77
|
+
pooledBrowser.pages = pooledBrowser.pages.filter((p) => p !== idle.page);
|
|
78
|
+
pooledBrowser.activePages.delete(idle.page);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const context = await playwrightBrowser.newContext({
|
|
83
|
+
viewport: {
|
|
84
|
+
width: profileContext.viewport.width,
|
|
85
|
+
height: profileContext.viewport.height,
|
|
86
|
+
},
|
|
87
|
+
isMobile: profileContext.viewport.isMobile,
|
|
88
|
+
hasTouch: profileContext.viewport.hasTouch,
|
|
89
|
+
userAgent: profileContext.userAgent,
|
|
90
|
+
locale: profileContext.locale,
|
|
91
|
+
timezoneId: profileContext.timezone,
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
const page = await context.newPage();
|
|
95
|
+
const pooledPage: PooledPage = {
|
|
96
|
+
page,
|
|
97
|
+
context,
|
|
98
|
+
profileContext,
|
|
99
|
+
inUse: true,
|
|
100
|
+
lastUsed: Date.now(),
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
pooledBrowser.pages.push(page);
|
|
104
|
+
pooledBrowser.activePages.add(page);
|
|
105
|
+
this.pagePool.push(pooledPage);
|
|
106
|
+
|
|
107
|
+
page.on('close', () => {
|
|
108
|
+
pooledBrowser?.activePages.delete(page);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
return page;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Release a page back to the pool
|
|
116
|
+
*/
|
|
117
|
+
releasePage(page: Page): void {
|
|
118
|
+
const pooledPageIndex = this.pagePool.findIndex((p) => p.page === page);
|
|
119
|
+
if (pooledPageIndex !== -1) {
|
|
120
|
+
const [pooledPage] = this.pagePool.splice(pooledPageIndex, 1);
|
|
121
|
+
pooledPage.inUse = false;
|
|
122
|
+
|
|
123
|
+
// Close page and context asynchronously to free browser memory
|
|
124
|
+
Promise.all([
|
|
125
|
+
!page.isClosed() ? page.close().catch(() => {}) : Promise.resolve(),
|
|
126
|
+
pooledPage.context.close().catch(() => {}),
|
|
127
|
+
]).finally(() => {
|
|
128
|
+
for (const pooledBrowser of this.browsers.values()) {
|
|
129
|
+
pooledBrowser.pages = pooledBrowser.pages.filter((p) => p !== page);
|
|
130
|
+
pooledBrowser.activePages.delete(page);
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Register a browser instance
|
|
138
|
+
*/
|
|
139
|
+
registerBrowser(key: string, browser: Browser): void {
|
|
140
|
+
if (this.browsers.has(key)) {
|
|
141
|
+
const existing = this.browsers.get(key)!;
|
|
142
|
+
existing.browser = browser;
|
|
143
|
+
existing.lastUsed = Date.now();
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (this.browsers.size >= this.config.maxBrowsers) {
|
|
148
|
+
throw new Error(`Browser pool full: max ${this.config.maxBrowsers} browsers reached`);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
this.browsers.set(key, {
|
|
152
|
+
browser,
|
|
153
|
+
pages: [],
|
|
154
|
+
activePages: new Set(),
|
|
155
|
+
lastUsed: Date.now(),
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Close all resources in the pool
|
|
161
|
+
*/
|
|
162
|
+
async closeAll(): Promise<void> {
|
|
163
|
+
if (this.cleanupInterval) {
|
|
164
|
+
clearInterval(this.cleanupInterval);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Close all pages
|
|
168
|
+
for (const pooledPage of this.pagePool) {
|
|
169
|
+
try {
|
|
170
|
+
await pooledPage.context.close();
|
|
171
|
+
} catch {
|
|
172
|
+
// Already closed
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Close all browsers
|
|
177
|
+
for (const pooledBrowser of this.browsers.values()) {
|
|
178
|
+
try {
|
|
179
|
+
await pooledBrowser.browser.close();
|
|
180
|
+
} catch {
|
|
181
|
+
// Already closed
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
this.pagePool = [];
|
|
186
|
+
this.browsers.clear();
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Get pool statistics
|
|
191
|
+
*/
|
|
192
|
+
getStats(): {
|
|
193
|
+
totalBrowsers: number;
|
|
194
|
+
totalPages: number;
|
|
195
|
+
activePages: number;
|
|
196
|
+
availablePages: number;
|
|
197
|
+
} {
|
|
198
|
+
let totalPages = 0;
|
|
199
|
+
let activePages = 0;
|
|
200
|
+
|
|
201
|
+
for (const pooledBrowser of this.browsers.values()) {
|
|
202
|
+
totalPages += pooledBrowser.pages.length;
|
|
203
|
+
activePages += pooledBrowser.activePages.size;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return {
|
|
207
|
+
totalBrowsers: this.browsers.size,
|
|
208
|
+
totalPages,
|
|
209
|
+
activePages,
|
|
210
|
+
availablePages: totalPages - activePages,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Start periodic cleanup of idle resources
|
|
216
|
+
*/
|
|
217
|
+
private startCleanupInterval(): void {
|
|
218
|
+
this.cleanupInterval = setInterval(() => {
|
|
219
|
+
this.cleanupIdleResources();
|
|
220
|
+
}, 10000); // Check every 10 seconds
|
|
221
|
+
|
|
222
|
+
// Unref to not keep process alive
|
|
223
|
+
if (this.cleanupInterval.unref) {
|
|
224
|
+
this.cleanupInterval.unref();
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Clean up idle pages and browsers
|
|
230
|
+
*/
|
|
231
|
+
private async cleanupIdleResources(): Promise<void> {
|
|
232
|
+
const now = Date.now();
|
|
233
|
+
|
|
234
|
+
// Remove idle pages
|
|
235
|
+
const idlePages = this.pagePool.filter(
|
|
236
|
+
(p) => !p.inUse && now - (p.lastUsed || now) > this.config.idleTimeout
|
|
237
|
+
);
|
|
238
|
+
|
|
239
|
+
for (const pooledPage of idlePages) {
|
|
240
|
+
try {
|
|
241
|
+
await pooledPage.context.close();
|
|
242
|
+
this.pagePool = this.pagePool.filter((p) => p !== pooledPage);
|
|
243
|
+
} catch {
|
|
244
|
+
// Already closed
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Remove idle browsers
|
|
249
|
+
for (const [key, pooledBrowser] of this.browsers.entries()) {
|
|
250
|
+
const isIdle =
|
|
251
|
+
pooledBrowser.activePages.size === 0 &&
|
|
252
|
+
now - pooledBrowser.lastUsed > this.config.idleTimeout;
|
|
253
|
+
|
|
254
|
+
if (isIdle) {
|
|
255
|
+
try {
|
|
256
|
+
await pooledBrowser.browser.close();
|
|
257
|
+
this.browsers.delete(key);
|
|
258
|
+
|
|
259
|
+
// Clean up associated pages
|
|
260
|
+
this.pagePool = this.pagePool.filter((p) => !pooledBrowser.pages.includes(p.page));
|
|
261
|
+
} catch {
|
|
262
|
+
// Already closed
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/core/concurrency-resolver.ts
|
|
3
|
+
*
|
|
4
|
+
* Implements Phase 1 concurrency resolution, hosted-environment guards,
|
|
5
|
+
* and deterministic bounded-concurrency execution pool.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import * as fs from 'fs';
|
|
9
|
+
import * as path from 'path';
|
|
10
|
+
import * as os from 'os';
|
|
11
|
+
import { ConcurrencyOptions } from '../types/contracts';
|
|
12
|
+
|
|
13
|
+
export interface ResolvedConcurrency {
|
|
14
|
+
concurrency: boolean;
|
|
15
|
+
concurrencyAmount: number;
|
|
16
|
+
isHosted: boolean;
|
|
17
|
+
downgradedDueToHosted: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Detects whether the current process is running in a constrained hosted environment
|
|
22
|
+
* (Render.com or any service with HAUNT_HOSTED=1).
|
|
23
|
+
*/
|
|
24
|
+
export function isHostedEnvironment(): boolean {
|
|
25
|
+
return (
|
|
26
|
+
process.env.RENDER === 'true' ||
|
|
27
|
+
process.env.HAUNT_HOSTED === '1' ||
|
|
28
|
+
process.env.HAUNT_HOSTED === 'true'
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Checks whether an operator has explicitly authorized concurrency in a hosted environment.
|
|
34
|
+
*/
|
|
35
|
+
export function isConcurrencyAllowedInHosted(): boolean {
|
|
36
|
+
return (
|
|
37
|
+
process.env.HAUNT_ALLOW_CONCURRENCY === '1' || process.env.HAUNT_ALLOW_CONCURRENCY === 'true'
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Attempts to load stored user config from ~/.hauntview/config.json
|
|
43
|
+
*/
|
|
44
|
+
export function loadUserConfig(): Partial<ConcurrencyOptions> | null {
|
|
45
|
+
try {
|
|
46
|
+
const configPath = path.join(os.homedir(), '.hauntview', 'config.json');
|
|
47
|
+
if (fs.existsSync(configPath)) {
|
|
48
|
+
const content = fs.readFileSync(configPath, 'utf8');
|
|
49
|
+
return JSON.parse(content);
|
|
50
|
+
}
|
|
51
|
+
} catch {
|
|
52
|
+
// Ignore invalid/unreadable config files
|
|
53
|
+
}
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Resolves concurrency options according to the strict 4-level precedence:
|
|
59
|
+
* 1. Explicit per-call options
|
|
60
|
+
* 2. Environment variables: HAUNT_CONCURRENCY and HAUNT_CONCURRENCY_AMOUNT
|
|
61
|
+
* 3. User configuration file (~/.hauntview/config.json)
|
|
62
|
+
* 4. Built-in defaults: concurrency=false, concurrencyAmount=2
|
|
63
|
+
*
|
|
64
|
+
* Hosted Guard: If running in a hosted environment without explicit override,
|
|
65
|
+
* concurrency is forced to false.
|
|
66
|
+
*/
|
|
67
|
+
export function resolveConcurrency(
|
|
68
|
+
options?: Partial<ConcurrencyOptions>,
|
|
69
|
+
configOverride?: Partial<ConcurrencyOptions>
|
|
70
|
+
): ResolvedConcurrency {
|
|
71
|
+
const isHosted = isHostedEnvironment();
|
|
72
|
+
const allowHosted = isConcurrencyAllowedInHosted();
|
|
73
|
+
const userConfig = configOverride ?? loadUserConfig() ?? {};
|
|
74
|
+
|
|
75
|
+
// 1. Resolve raw concurrency boolean
|
|
76
|
+
let rawConcurrency: boolean;
|
|
77
|
+
if (options?.concurrency !== undefined) {
|
|
78
|
+
rawConcurrency = Boolean(options.concurrency);
|
|
79
|
+
} else if (process.env.HAUNT_CONCURRENCY !== undefined) {
|
|
80
|
+
rawConcurrency =
|
|
81
|
+
process.env.HAUNT_CONCURRENCY === 'true' || process.env.HAUNT_CONCURRENCY === '1';
|
|
82
|
+
} else if (userConfig.concurrency !== undefined) {
|
|
83
|
+
rawConcurrency = Boolean(userConfig.concurrency);
|
|
84
|
+
} else {
|
|
85
|
+
rawConcurrency = false;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// 2. Resolve raw concurrency amount (int >= 1, default 2)
|
|
89
|
+
let rawAmount: number;
|
|
90
|
+
if (options?.concurrencyAmount !== undefined) {
|
|
91
|
+
rawAmount = Math.max(1, Math.floor(Number(options.concurrencyAmount)));
|
|
92
|
+
} else if (process.env.HAUNT_CONCURRENCY_AMOUNT !== undefined) {
|
|
93
|
+
const parsed = parseInt(process.env.HAUNT_CONCURRENCY_AMOUNT, 10);
|
|
94
|
+
rawAmount = Number.isNaN(parsed) || parsed < 1 ? 2 : parsed;
|
|
95
|
+
} else if (userConfig.concurrencyAmount !== undefined) {
|
|
96
|
+
rawAmount = Math.max(1, Math.floor(Number(userConfig.concurrencyAmount)));
|
|
97
|
+
} else {
|
|
98
|
+
rawAmount = 2;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// 3. Apply Hosted Guard
|
|
102
|
+
let effectiveConcurrency = rawConcurrency;
|
|
103
|
+
let downgradedDueToHosted = false;
|
|
104
|
+
|
|
105
|
+
if (isHosted && !allowHosted && rawConcurrency) {
|
|
106
|
+
effectiveConcurrency = false;
|
|
107
|
+
downgradedDueToHosted = true;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
concurrency: effectiveConcurrency,
|
|
112
|
+
concurrencyAmount: rawAmount,
|
|
113
|
+
isHosted,
|
|
114
|
+
downgradedDueToHosted,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Executes a list of async task factories with a bounded concurrency pool.
|
|
120
|
+
* Guarantees:
|
|
121
|
+
* - Active concurrency never exceeds `concurrencyLimit`.
|
|
122
|
+
* - Result ordering strictly matches the input task order.
|
|
123
|
+
* - Failure isolation: exceptions handled via `onFailure` without aborting other tasks.
|
|
124
|
+
*/
|
|
125
|
+
export async function executeWithConcurrency<T>(
|
|
126
|
+
tasks: (() => Promise<T>)[],
|
|
127
|
+
concurrencyLimit: number,
|
|
128
|
+
options?: { onFailure?: (index: number, error: unknown) => T }
|
|
129
|
+
): Promise<T[]> {
|
|
130
|
+
const limit = Math.max(1, concurrencyLimit);
|
|
131
|
+
const results: T[] = new Array(tasks.length);
|
|
132
|
+
let nextTaskIndex = 0;
|
|
133
|
+
|
|
134
|
+
async function worker() {
|
|
135
|
+
while (nextTaskIndex < tasks.length) {
|
|
136
|
+
const currentIndex = nextTaskIndex++;
|
|
137
|
+
try {
|
|
138
|
+
results[currentIndex] = await tasks[currentIndex]();
|
|
139
|
+
} catch (err) {
|
|
140
|
+
if (options?.onFailure) {
|
|
141
|
+
results[currentIndex] = options.onFailure(currentIndex, err);
|
|
142
|
+
} else {
|
|
143
|
+
throw err;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const workerCount = Math.min(limit, tasks.length);
|
|
150
|
+
const workers = Array.from({ length: workerCount }, () => worker());
|
|
151
|
+
|
|
152
|
+
await Promise.all(workers);
|
|
153
|
+
return results;
|
|
154
|
+
}
|