Skip to content
Merged
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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
node_modules/
zig-out/
.benchtree/
images/gen/*
!images/gen/.gitkeep

# node js + playwright
node_modules/
Expand Down
26 changes: 23 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ odiff --help

NodeJS Api is pretty tiny as well. Here is a typescript interface we have:


<!--inline-interface-start-->
```tsx
export type ODiffOptions = Partial<{
/** Color used to highlight different pixels in the output (in hex format e.g. #cd2cc9). */
Expand Down Expand Up @@ -220,7 +220,7 @@ declare function compare(
* server.stop();
* ```
*
* It is absolutely fine to keep odiff server living in the module root
* It is absolutely fine to keep odiff sever leaving in the module root
* even if you have several independent workers, it will automatically spawn
* a server process per each multiplexed core to work in parallel
*
Expand Down Expand Up @@ -261,6 +261,27 @@ export declare class ODiffServer {
options?: ODiffOptions & { timeout?: number },
): Promise<ODiffResult>;

/**
* Compare two images buffers, the buffer data is the actual encoded file bytes.
* **Important**: Always prefer file paths compare if you are saving images to disk anyway.
*
* @param baseBuffer - Buffer containing base image data
* @param baseFormat - Format of base image: "png", "jpeg", "jpg", "bmp", "tiff", "webp"
* @param compareBuffer - Buffer containing compare image data
* @param compareFormat - Format of compare image: "png", "jpeg", "jpg", "bmp", "tiff", "webp"
* @param diffOutput - Path to output diff image
* @param options - Comparison options with optional timeout for request
* @returns Promise resolving to comparison result
*/
compareBuffers(
baseBuffer: Buffer,
baseFormat: string,
compareBuffer: Buffer,
compareFormat: string,
diffOutput: string,
options?: ODiffOptions & { timeout?: number },
): Promise<ODiffResult>;

/**
* Stop the odiff server process
* Should be called when done with all comparisons
Expand All @@ -271,7 +292,6 @@ export declare class ODiffServer {

export { compare, ODiffServer };
```

<!--inline-interface-end-->

Compare option will return `{ match: true }` if images are identical. Otherwise return `{ match: false, reason: "*" }` with a reason why images were different.
Expand Down
Binary file modified images/diff.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added images/diff_buffer_different.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified images/diff_white_mask.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Empty file added images/gen/.gitkeep
Empty file.
3 changes: 2 additions & 1 deletion npm_packages/odiff-bin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ odiff --help

NodeJS Api is pretty tiny as well. Here is a typescript interface we have:


<!--inline-interface-start-->
```tsx
export type ODiffOptions = Partial<{
/** Color used to highlight different pixels in the output (in hex format e.g. #cd2cc9). */
Expand Down Expand Up @@ -274,6 +274,7 @@ export { compare, ODiffServer };

<!--inline-interface-end-->


Compare option will return `{ match: true }` if images are identical. Otherwise return `{ match: false, reason: "*" }` with a reason why images were different.

> Make sure that diff output file will be created only if images have pixel difference we can see 👀
Expand Down
21 changes: 21 additions & 0 deletions npm_packages/odiff-bin/odiff.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,27 @@ export declare class ODiffServer {
options?: ODiffOptions & { timeout?: number },
): Promise<ODiffResult>;

/**
* Compare two images buffers, the buffer data is the actual encoded file bytes.
* **Important**: Always prefer file paths compare if you are saving images to disk anyway.
*
* @param baseBuffer - Buffer containing base image data
* @param baseFormat - Format of base image: "png", "jpeg", "jpg", "bmp", "tiff", "webp"
* @param compareBuffer - Buffer containing compare image data
* @param compareFormat - Format of compare image: "png", "jpeg", "jpg", "bmp", "tiff", "webp"
* @param diffOutput - Path to output diff image
* @param options - Comparison options with optional timeout for request
* @returns Promise resolving to comparison result
*/
compareBuffers(
baseBuffer: Buffer,
baseFormat: string,
compareBuffer: Buffer,
compareFormat: string,
diffOutput: string,
options?: ODiffOptions & { timeout?: number },
): Promise<ODiffResult>;

/**
* Stop the odiff server process
* Should be called when done with all comparisons
Expand Down
228 changes: 199 additions & 29 deletions npm_packages/odiff-bin/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,69 @@ class ODiffServer {
this.pendingRequests = new Map();
this.requestId = 0;
this.exiting = false;
this.writeLock = Promise.resolve();

// Start server initialization immediately
/** @type {Promise | null} */
this._initPromise = this._initialize();
}

/** @returns {Promise<(value?: unknown) => unknown>} */
async _acquireWriteLock() {
const currentLock = this.writeLock;
/** @type {(value?: unknown) => unknown} */
let releaseLock;
this.writeLock = new Promise((resolve) => {
releaseLock = resolve;
});
await currentLock;
return releaseLock;
}

/**
* This is internal node js bs handling a separate buffer for stdin
* that can be overflown or underflow, so we have to wait for it to
* process the data otherwise the data corruption can happen
* @private
* @param {string | Buffer} data - Data to write
* @returns {Promise<void>}
*/
async _writeWithBackpressure(data) {
const noDrainNeeded = this.process?.stdin.write(data);
if (!noDrainNeeded) {
await new Promise((resolve, reject) => {
const stdin = this.process?.stdin;
if (!stdin) {
reject(new Error("Process stdin not available"));
return;
}

let settled = false;

stdin.once("drain", () => {
if (!settled) {
settled = true;
resolve(undefined);
}
});

stdin.once("error", (err) => {
if (!settled) {
settled = true;
reject(err);
}
});

stdin.once("close", () => {
if (!settled) {
settled = true;
reject(new Error("Stream closed before drain"));
}
});
});
}
}

/**
* Internal method to initialize the server process
* @private
Expand Down Expand Up @@ -77,7 +134,12 @@ class ODiffServer {
clearTimeout(pending.timeoutId);
}

pending.resolve(response);
// Reject if response contains an error, otherwise resolve
if (response.error) {
pending.reject(new Error(response.error));
} else {
pending.resolve(response);
}
} else {
console.warn(
`Received response for unknown request ID: ${response.requestId}`,
Expand Down Expand Up @@ -114,7 +176,7 @@ class ODiffServer {
* @param {string} comparePath - Path to comparison image
* @param {string} diffOutput - Path to output diff image
* @param {import("./odiff.d.ts").ODiffOptions & { timeout?: number }} [options] - Comparison options
* @returns {Promise<Object>} Comparison result
* @returns {Promise<import("./odiff.d.ts").ODiffResult>} Comparison result
*/
async compare(basePath, comparePath, diffOutput, options = {}) {
if (this._initPromise && !this.ready) {
Expand All @@ -127,9 +189,10 @@ class ODiffServer {
await this._initPromise;
}

return new Promise((resolve, reject) => {
const requestId = this.requestId++;
let timeoutId;
const requestId = this.requestId++;
let timeoutId;

const resultPromise = new Promise((resolve, reject) => {
if (options.timeout !== undefined) {
timeoutId = setTimeout(() => {
if (this.pendingRequests.has(requestId)) {
Expand All @@ -142,33 +205,140 @@ class ODiffServer {
}

this.pendingRequests.set(requestId, { resolve, reject, timeoutId });
const request = {
requestId: requestId,
base: basePath,
compare: comparePath,
output: diffOutput,
options: {
threshold: options.threshold,
failOnLayoutDiff: options.failOnLayoutDiff,
antialiasing: options.antialiasing,
captureDiffLines: options.captureDiffLines,
outputDiffMask: options.outputDiffMask,
ignoreRegions: options.ignoreRegions,
diffColor: options.diffColor,
diffOverlay: options.diffOverlay,
},
};
});

try {
this.process?.stdin.write(JSON.stringify(request) + "\n");
} catch (err) {
this.pendingRequests.delete(requestId);
if (timeoutId !== undefined) {
clearTimeout(timeoutId);
}
reject(new Error(`odiff: Failed to send request: ${err.message}`));
const request = {
requestId: requestId,
base: basePath,
compare: comparePath,
output: diffOutput,
options: {
threshold: options.threshold,
failOnLayoutDiff: options.failOnLayoutDiff,
antialiasing: options.antialiasing,
captureDiffLines: options.captureDiffLines,
outputDiffMask: options.outputDiffMask,
ignoreRegions: options.ignoreRegions,
diffColor: options.diffColor,
diffOverlay: options.diffOverlay,
},
};

// Acquire write lock to prevent concurrent requests from interleaving
const release = await this._acquireWriteLock();
try {
await this._writeWithBackpressure(JSON.stringify(request) + "\n");
} catch (err) {
this.pendingRequests.delete(requestId);
if (timeoutId !== undefined) {
clearTimeout(timeoutId);
}
throw new Error(`odiff: Failed to send request: ${err.message}`);
} finally {
release();
}

return resultPromise;
}

/**
* Compare two images buffers, the buffer data is the actual encoded file bytes.
* **Important**: Always prefer file paths compare if you are saving images to disk anyway.
*
* @param {Buffer} baseBuffer - Buffer containing base image data
* @param {"png" | "jpeg" | "bmp" | "tiff" | "webp"} baseFormat - Format: "png", "jpeg", "bmp", "tiff", "webp"
* @param {Buffer} compareBuffer - Buffer containing compare image data
* @param {"png" | "jpeg" | "bmp" | "tiff" | "webp"} compareFormat - Format of compare image
* @param {string} diffOutput - Path to output diff image
* @param {import("./odiff.d.ts").ODiffOptions & { timeout?: number }} [options] - Comparison options
* @returns {Promise<import("./odiff.d.ts").ODiffResult>} Comparison result
*/
async compareBuffers(
baseBuffer,
baseFormat,
compareBuffer,
compareFormat,
diffOutput,
options = {},
) {
// Wait for server initialization
if (this._initPromise && !this.ready) {
await this._initPromise;
}

if (!this._initPromise && !this.ready) {
this._initPromise = this._initialize();
await this._initPromise;
}

if (!Buffer.isBuffer(baseBuffer) || !Buffer.isBuffer(compareBuffer)) {
throw new Error(
"Both baseBuffer and compareBuffer must be Buffer instances",
);
}

const requestId = this.requestId++;
let timeoutId;

const resultPromise = new Promise((resolve, reject) => {
if (options.timeout !== undefined) {
timeoutId = setTimeout(() => {
if (this.pendingRequests.has(requestId)) {
this.pendingRequests.delete(requestId);
reject(
new Error(`odiff: Request timed out after ${options.timeout}ms`),
);
}
}, options.timeout);
}

this.pendingRequests.set(requestId, { resolve, reject, timeoutId });
});

const request = {
type: "buffer",
requestId: requestId,
baseLength: baseBuffer.length,
baseFormat: baseFormat,
compareLength: compareBuffer.length,
compareFormat: compareFormat,
output: diffOutput,
options: {
threshold: options.threshold,
failOnLayoutDiff: options.failOnLayoutDiff,
antialiasing: options.antialiasing,
captureDiffLines: options.captureDiffLines,
outputDiffMask: options.outputDiffMask,
ignoreRegions: options.ignoreRegions,
diffColor: options.diffColor,
diffOverlay: options.diffOverlay,
},
};

// Acquire write lock to prevent concurrent requests from interleaving
// This is CRITICAL for buffer comparisons since we write:
// 1. JSON request line
// 2. Base buffer (raw bytes)
// 3. Compare buffer (raw bytes)
// These three writes must be atomic and ordered to avoid data corruption
const release = await this._acquireWriteLock();
try {
await this._writeWithBackpressure(JSON.stringify(request) + "\n");
await this._writeWithBackpressure(baseBuffer);
await this._writeWithBackpressure(compareBuffer);
} catch (err) {
this.pendingRequests.delete(requestId);
if (timeoutId !== undefined) {
clearTimeout(timeoutId);
}
throw new Error(`odiff: Failed to send request: ${err.message}`);
} finally {
if (release) {
release();
}
}

return resultPromise;
}

/**
Expand Down
1 change: 1 addition & 0 deletions npm_packages/tests/ava.config.cjs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
module.exports = {
files: ["src/**/*.test.cjs"],
timeout: "60s",
};
Loading