Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions packages/screenshot/src/is-white-screenshot-analyze.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
'use strict'

const { Jimp } = require('jimp')

module.exports = async uint8array => {
try {
const image = await Jimp.fromBuffer(Buffer.from(uint8array))
const firstPixel = image.getPixelColor(0, 0)
const height = image.bitmap.height
const width = image.bitmap.width

// For 2D grid sampling, calculate stepSize to achieve approximately the target sample percentage.
// When sampling every 'stepSize' pixels in both dimensions, actual samples = (height/stepSize) * (width/stepSize).
// To achieve samplePercentage, we need: (h*w)/(stepSize²) ≈ samplePercentage*(h*w)
// Therefore: stepSize ≈ sqrt(1 / samplePercentage)
const samplePercentage = 0.25 // Sample ~25% of the image
const stepSize = Math.max(1, Math.ceil(Math.sqrt(1 / samplePercentage)))

for (let i = 0; i < height; i += stepSize) {
for (let j = 0; j < width; j += stepSize) {
if (firstPixel !== image.getPixelColor(j, i)) return false
}
}

return true
} catch (error) {
if (error.message.includes('maxMemoryUsageInMB')) {
return false
}
throw error
}
}
20 changes: 20 additions & 0 deletions packages/screenshot/src/is-white-screenshot-worker.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
'use strict'

const { parentPort } = require('worker_threads')

const analyze = require('./is-white-screenshot-analyze')

parentPort.on('message', async ({ id, uint8array }) => {
try {
const value = await analyze(uint8array)
parentPort.postMessage({ id, value })
} catch (error) {
parentPort.postMessage({
id,
error: {
message: error.message,
name: error.name
}
})
}
})
94 changes: 70 additions & 24 deletions packages/screenshot/src/is-white-screenshot.js
Original file line number Diff line number Diff line change
@@ -1,32 +1,78 @@
'use strict'

const { Jimp } = require('jimp')
const path = require('path')
const { Worker } = require('worker_threads')

module.exports = async uint8array => {
try {
const image = await Jimp.fromBuffer(Buffer.from(uint8array))
const firstPixel = image.getPixelColor(0, 0)
const height = image.bitmap.height
const width = image.bitmap.width

// For 2D grid sampling, calculate stepSize to achieve approximately the target sample percentage.
// When sampling every 'stepSize' pixels in both dimensions, actual samples = (height/stepSize) * (width/stepSize).
// To achieve samplePercentage, we need: (h*w)/(stepSize²) ≈ samplePercentage*(h*w)
// Therefore: stepSize ≈ sqrt(1 / samplePercentage)
const samplePercentage = 0.25 // Sample ~25% of the image
const stepSize = Math.max(1, Math.ceil(Math.sqrt(1 / samplePercentage)))

for (let i = 0; i < height; i += stepSize) {
for (let j = 0; j < width; j += stepSize) {
if (firstPixel !== image.getPixelColor(j, i)) return false
let worker
let messageId = 0

const pending = new Map()

const syncWorkerRef = instance => {
if (worker !== instance) return
if (typeof instance.ref !== 'function' || typeof instance.unref !== 'function') return
if (pending.size > 0) instance.ref()
else instance.unref()
}

const rejectPending = error => {
for (const { reject } of pending.values()) reject(error)
pending.clear()
}

const getWorker = () => {
if (worker) return worker

const instance = new Worker(path.resolve(__dirname, './is-white-screenshot-worker.js'))
worker = instance

syncWorkerRef(instance)

instance.on('message', ({ id, value, error }) => {
const resolver = pending.get(id)
if (!resolver) return
pending.delete(id)
syncWorkerRef(instance)
if (error) {
const err = new Error(error.message)
err.name = error.name || 'Error'
return resolver.reject(err)
}
resolver.resolve(value)
})

instance.on('error', error => {
if (worker === instance) {
rejectPending(error)
worker = undefined
}
})

instance.on('exit', code => {
if (worker === instance) {
if (pending.size > 0) {
rejectPending(new Error(`is-white-screenshot worker exited with code ${code}`))
}
worker = undefined
}
})

return instance
}

module.exports = async uint8array => {
return new Promise((resolve, reject) => {
const activeWorker = getWorker()
const id = ++messageId
pending.set(id, { resolve, reject })
syncWorkerRef(activeWorker)

return true
} catch (error) {
if (error.message.includes('maxMemoryUsageInMB')) {
return false
try {
activeWorker.postMessage({ id, uint8array: Buffer.from(uint8array) })
} catch (error) {
pending.delete(id)
syncWorkerRef(activeWorker)
reject(error)
}
throw error
}
})
}
221 changes: 221 additions & 0 deletions packages/screenshot/test/is-white-screenshot-worker.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
'use strict'

const { spawnSync } = require('child_process')
const path = require('path')
const test = require('ava')

test.serial('uses worker thread path when available', t => {
const isWhitePath = path.resolve(__dirname, '../src/is-white-screenshot.js')
const whiteFixture = path.resolve(__dirname, './fixtures/white-5k.png')
const nonWhiteFixture = path.resolve(__dirname, './fixtures/no-white-5k.png')

const script = `
const fs = require('fs')
const workerThreads = require('worker_threads')
const OriginalWorker = workerThreads.Worker
let workerCount = 0

workerThreads.Worker = class WorkerSpy extends OriginalWorker {
constructor (...args) {
workerCount += 1
super(...args)
}
}

const isWhite = require(${JSON.stringify(isWhitePath)})

Promise.all([
isWhite(fs.readFileSync(${JSON.stringify(whiteFixture)})),
isWhite(fs.readFileSync(${JSON.stringify(nonWhiteFixture)}))
])
.then(([white, nonWhite]) => {
process.stdout.write(JSON.stringify({ workerCount, white, nonWhite }))
process.exit(0)
})
.catch(error => {
console.error(error)
process.exit(1)
})
`

const { status, stdout, stderr } = spawnSync(process.execPath, ['-e', script], {
encoding: 'utf8'
})

t.is(status, 0, stderr)
const result = JSON.parse(stdout.trim())
t.true(result.workerCount >= 1)
t.true(result.white)
t.false(result.nonWhite)
})

test.serial('rejects in-flight requests when worker exits with code 0', t => {
const isWhitePath = path.resolve(__dirname, '../src/is-white-screenshot.js')
const whiteFixture = path.resolve(__dirname, './fixtures/white-5k.png')

const script = `
const workerThreads = require('worker_threads')
const OriginalWorker = workerThreads.Worker

workerThreads.Worker = class WorkerExitZero extends OriginalWorker {
constructor () {
super(
"const { parentPort } = require('worker_threads'); parentPort.on('message', () => process.exit(0))",
{ eval: true }
)
}
}

const isWhite = require(${JSON.stringify(isWhitePath)})
const screenshot = fs.readFileSync(${JSON.stringify(whiteFixture)})
const timeout = setTimeout(() => {
process.stdout.write(JSON.stringify({ timedOut: true }))
process.exit(0)
}, 1500)

isWhite(screenshot)
.then(() => {
clearTimeout(timeout)
process.stdout.write(JSON.stringify({ resolved: true }))
process.exit(0)
})
.catch(error => {
clearTimeout(timeout)
process.stdout.write(JSON.stringify({ rejected: true, message: error.message }))
process.exit(0)
})
`
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing fs require in second test script string

High Severity

The inline script string for the "rejects in-flight requests when worker exits with code 0" test uses fs.readFileSync on line 70 but never requires fs. The other two test scripts both include const fs = require('fs') at the top of their script strings, but this one is missing it. The spawned child process will crash with a ReferenceError, causing the test to always fail.

Fix in Cursor Fix in Web


const { status, stdout, stderr } = spawnSync(process.execPath, ['-e', script], {
encoding: 'utf8'
})

t.is(status, 0, stderr)
const result = JSON.parse(stdout.trim())
t.false(Boolean(result.timedOut), 'request should not hang when worker exits')
t.true(Boolean(result.rejected), 'request should be rejected on worker exit')
t.regex(result.message, /exited with code 0/)
})

test.serial('stale worker exit does not reject new worker promises', t => {
const isWhitePath = path.resolve(__dirname, '../src/is-white-screenshot.js')
const tinyWhitePngBase64 =
'iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAYAAABytg0kAAAAEUlEQVR4AWP8DwQMQMDEAAUAPfgEADYYS7QAAAAASUVORK5CYII='

const script = `
const fs = require('fs')
const workerThreads = require('worker_threads')
const OriginalWorker = workerThreads.Worker

let callCount = 0
let firstErrorMessage

workerThreads.Worker = class WorkerCrashOnce extends OriginalWorker {
constructor (...args) {
callCount += 1
if (callCount === 1) {
// First worker crashes immediately on any message
super(
"const { parentPort } = require('worker_threads'); parentPort.on('message', () => { throw new Error('boom') })",
{ eval: true }
)
} else {
// Subsequent workers behave normally
super(...args)
}
}
}

const isWhite = require(${JSON.stringify(isWhitePath)})
const screenshot = Buffer.from(${JSON.stringify(tinyWhitePngBase64)}, 'base64')

const timeout = setTimeout(() => {
process.stdout.write(JSON.stringify({ timedOut: true, callCount }))
process.exit(1)
}, 5000)

process.on('uncaughtException', error => {
clearTimeout(timeout)
process.stdout.write(
JSON.stringify({
ok: false,
uncaughtException: true,
message: error.message,
stack: error.stack,
callCount,
firstErrorMessage
})
)
process.exit(1)
})

process.on('unhandledRejection', error => {
clearTimeout(timeout)
process.stdout.write(
JSON.stringify({
ok: false,
unhandledRejection: true,
message: error && error.message,
stack: error && error.stack,
callCount,
firstErrorMessage
})
)
process.exit(1)
})

isWhite(screenshot)
.catch(error => {
firstErrorMessage = error && error.message
return isWhite(screenshot)
})
.then(result => {
clearTimeout(timeout)
process.stdout.write(JSON.stringify({ ok: true, result, callCount, firstErrorMessage }))
process.exit(0)
})
.catch(error => {
clearTimeout(timeout)
process.stdout.write(
JSON.stringify({
ok: false,
message: error.message,
stack: error.stack,
callCount,
firstErrorMessage
})
)
process.exit(1)
})
`

const { status, stdout, stderr, signal, error } = spawnSync(process.execPath, ['-e', script], {
encoding: 'utf8',
timeout: 10000
})

const debugPayload = JSON.stringify(
{
status,
signal,
error: error
? {
message: error.message,
name: error.name,
code: error.code
}
: null,
stdout: stdout.trim(),
stderr: stderr.trim()
},
null,
2
)

t.is(status, 0, debugPayload)
const result = JSON.parse(stdout.trim())
t.false(Boolean(result.timedOut), 'retry should not hang')
t.true(result.ok, `retry should succeed, got: ${result.message || 'no error'}`)
t.is(result.callCount, 2, 'should have spawned exactly 2 workers')
t.true(result.result, 'white screenshot should be detected as white')
})
3 changes: 2 additions & 1 deletion packages/screenshot/test/is-white-screenshot.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ const { readFile } = require('fs/promises')
const { Jimp } = require('jimp')

const isWhite = require('../src/is-white-screenshot')
const analyze = require('../src/is-white-screenshot-analyze')

const createJimpSpy = () => {
const originalFromBuffer = Jimp.fromBuffer
Expand Down Expand Up @@ -49,7 +50,7 @@ test('sampling algorithm correctly samples ~25% of pixels', async t => {
const totalPixels = tempImage.bitmap.width * tempImage.bitmap.height
const { spy, restore } = createJimpSpy()

await isWhite(imageBuffer)
await analyze(imageBuffer)
restore()

const percentageChecked = (spy.callCount / totalPixels) * 100
Expand Down