diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0980c195d..6a93af796 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -240,6 +240,23 @@ jobs: - name: Run typecheck run: pnpm typecheck + freerange: + name: FreeRange + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup toolchain + uses: ./.github/actions/setup-node-pnpm + + - name: Setup Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + + - name: Check numeric ranges + run: pnpm check:freerange + integration: name: Integration Tests runs-on: ubuntu-latest diff --git a/package.json b/package.json index e2e7113a5..905843e45 100644 --- a/package.json +++ b/package.json @@ -120,6 +120,7 @@ "check:layering": "node --experimental-strip-types --test scripts/layering/model.test.ts && node --experimental-strip-types scripts/layering/check.ts", "check:production-exports": "fallow dead-code --config fallow-production-exports.json --production --unused-exports --fail-on-issues", "check:bundle-owner-files": "node --experimental-strip-types scripts/check-bundle-owner-files.ts", + "check:freerange": "fr", "check:quick": "pnpm lint && pnpm typecheck", "sync:mcp-metadata": "node scripts/sync-mcp-metadata.mjs", "check:mcp-metadata": "node scripts/sync-mcp-metadata.mjs --check", @@ -224,8 +225,8 @@ "yaml": "^2.9.0" }, "devDependencies": { + "@chenglou/freerange": "^0.0.1", "@types/node": "^22.19.21", - "typescript": "^7.0.2", "@vitest/coverage-v8": "4.1.8", "fallow": "^2.95.0", "oxc-parser": "^0.138.0", @@ -233,6 +234,7 @@ "oxlint": "^1.69.0", "skillgym": "^0.9.1", "tsdown": "^0.22.4", + "typescript": "^7.0.2", "vite": "^8.0.16", "vitest": "^4.1.8" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d16213c19..25529ab4d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -15,6 +15,9 @@ importers: specifier: ^2.9.0 version: 2.9.0 devDependencies: + '@chenglou/freerange': + specifier: ^0.0.1 + version: 0.0.1 '@types/node': specifier: ^22.19.21 version: 22.19.21 @@ -93,6 +96,10 @@ packages: react: ^19.2.4 react-dom: ^19.2.4 + '@chenglou/freerange@0.0.1': + resolution: {integrity: sha512-RCdvTZX66Dp5roRrld+2GH4tJV+uyo21nEsF/lxwDBjzDFagG9CnJ7go5Qim2ZDHTC40lQWNF1AprDxTDQTxfg==} + hasBin: true + '@emnapi/core@1.10.0': resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} @@ -2265,6 +2272,11 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} + engines: {node: '>=14.17'} + hasBin: true + typescript@7.0.2: resolution: {integrity: sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==} engines: {node: '>=16.20.0'} @@ -2486,6 +2498,10 @@ snapshots: react: 19.2.7 react-dom: 19.2.7(react@19.2.7) + '@chenglou/freerange@0.0.1': + dependencies: + typescript: 6.0.3 + '@emnapi/core@1.10.0': dependencies: '@emnapi/wasi-threads': 1.2.1 @@ -4702,6 +4718,8 @@ snapshots: tslib@2.8.1: {} + typescript@6.0.3: {} + typescript@7.0.2: optionalDependencies: '@typescript/typescript-aix-ppc64': 7.0.2 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 9b90dcfdd..0347364c9 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -5,3 +5,5 @@ pmOnFail: warn verifyDepsBeforeRun: warn allowBuilds: fallow: true +minimumReleaseAgeExclude: + - '@chenglou/freerange@0.0.1' diff --git a/src/contracts/scroll-gesture.test.ts b/src/contracts/scroll-gesture.test.ts index 56f82b6ac..42c3745e7 100644 --- a/src/contracts/scroll-gesture.test.ts +++ b/src/contracts/scroll-gesture.test.ts @@ -221,3 +221,7 @@ test('clampGestureCoordinate rounds values and clamps them into the safe gesture assert.equal(clampGestureCoordinate(2.6, 8, 100), 8); assert.equal(clampGestureCoordinate(97.6, 8, 100), 92); }); + +test('clampGestureCoordinate returns the lower bound for non-finite coordinates', () => { + assert.equal(clampGestureCoordinate(Number.POSITIVE_INFINITY, 8, 100), 8); +}); diff --git a/src/contracts/scroll-gesture.ts b/src/contracts/scroll-gesture.ts index 359814ef2..4ff88cd73 100644 --- a/src/contracts/scroll-gesture.ts +++ b/src/contracts/scroll-gesture.ts @@ -241,9 +241,11 @@ function pointFromPercent( xPercent: number, yPercent: number, ): GesturePoint { + const x = Math.trunc((frame.referenceWidth * xPercent) / 100); + const y = Math.trunc((frame.referenceHeight * yPercent) / 100); return { - x: Math.trunc((frame.referenceWidth * xPercent) / 100), - y: Math.trunc((frame.referenceHeight * yPercent) / 100), + x: Number.isFinite(x) ? x : 0, + y: Number.isFinite(y) ? y : 0, }; } @@ -307,11 +309,11 @@ function normalizeRequestedPixels(pixels: number): number { } export function clampGestureCoordinate(value: number, marginPx: number, size: number): number { - const min = marginPx; - const max = Math.max(min, size - marginPx); - return clampToRange(value, min, max); -} + const min = Math.round(marginPx); + if (!Number.isFinite(min)) return 0; + + const max = Math.max(min, Math.round(size - marginPx)); + if (!Number.isFinite(max) || !Number.isFinite(value)) return min; -function clampToRange(value: number, min: number, max: number): number { - return Math.min(Math.round(max), Math.max(Math.round(min), Math.round(value))); + return Math.min(max, Math.max(min, Math.round(value))); } diff --git a/src/daemon/handlers/record-trace-android.ts b/src/daemon/handlers/record-trace-android.ts index e496caeac..91e1002dd 100644 --- a/src/daemon/handlers/record-trace-android.ts +++ b/src/daemon/handlers/record-trace-android.ts @@ -215,7 +215,13 @@ function scaledSizeToMax(size: AndroidRecordingSize & { maxSize: number }): Andr if (longest <= size.maxSize) { return { width: size.width, height: size.height }; } + if (longest === 0) { + return { width: size.width, height: size.height }; + } const scale = size.maxSize / longest; + if (!Number.isFinite(scale)) { + return { width: size.width, height: size.height }; + } return { width: scaledEvenDimension(size.width, scale), height: scaledEvenDimension(size.height, scale), diff --git a/src/screenshot-diff/screenshot-diff-region-overlay.ts b/src/screenshot-diff/screenshot-diff-region-overlay.ts index 5148a4111..22b74fcb8 100644 --- a/src/screenshot-diff/screenshot-diff-region-overlay.ts +++ b/src/screenshot-diff/screenshot-diff-region-overlay.ts @@ -18,10 +18,12 @@ export function annotateDiffRegions(diff: PNG, regions: ScreenshotDiffRegion[]): } function drawRect(diff: PNG, rect: ScreenshotDiffRegion['rect']): void { - const minX = clamp(rect.x, 0, diff.width - 1); - const minY = clamp(rect.y, 0, diff.height - 1); - const maxX = clamp(rect.x + rect.width - 1, 0, diff.width - 1); - const maxY = clamp(rect.y + rect.height - 1, 0, diff.height - 1); + const bounds = resolveFiniteRectBounds(rect); + if (bounds == null) return; + const minX = clamp(bounds.x, 0, diff.width - 1); + const minY = clamp(bounds.y, 0, diff.height - 1); + const maxX = clamp(bounds.right, 0, diff.width - 1); + const maxY = clamp(bounds.bottom, 0, diff.height - 1); for (let thickness = 0; thickness < REGION_BORDER_THICKNESS; thickness += 1) { for (let x = minX; x <= maxX; x += 1) { setPixel(diff, x, minY + thickness, REGION_BORDER_COLOR); @@ -34,6 +36,19 @@ function drawRect(diff: PNG, rect: ScreenshotDiffRegion['rect']): void { } } +function resolveFiniteRectBounds(rect: ScreenshotDiffRegion['rect']): { + x: number; + y: number; + right: number; + bottom: number; +} | null { + const right = rect.x + rect.width - 1; + const bottom = rect.y + rect.height - 1; + const values = [rect.x, rect.y, rect.width, rect.height, right, bottom]; + if (!values.every(Number.isFinite)) return null; + return { x: rect.x, y: rect.y, right, bottom }; +} + function setPixel( diff: PNG, x: number, @@ -49,5 +64,6 @@ function setPixel( } function clamp(value: number, min: number, max: number): number { + if (!Number.isFinite(value)) return min; return Math.min(Math.max(value, min), max); }