Portar el motor de comparación a TypeScript para la versión web

Mismo motor que la app de escritorio, pero client-side: los ficheros nunca
salen del navegador. Listar un EAR sólo lee la cola del fichero y las entradas
se inflan bajo demanda con DecompressionStream, que es lo que hace usable un
archivo grande en una pestaña.

- zip.ts: lector del directorio central, ZIP64, detección por firma
- content.ts: binario/texto/semántico; JSON por estructura y XML canónico con
  hermanos repetidos tratados como conjunto
- diff.ts: alineación LCS con recorte de prefijo/sufijo y clasificación de la
  diferencia (espacios / comentario / contenido)
- tree.ts: árboles de carpetas y archivos, emparejado por nombre y CRC32 del
  directorio central para resolver iguales sin leer bytes

34 tests portados de los del motor Swift: existen para probar que ambas
implementaciones dan el mismo veredicto.

Closes #5
Closes #6

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015hEAYuHRKMYz9sSa9zmbzz
This commit is contained in:
alexandrev-tibco
2026-07-31 20:24:09 +02:00
parent 8e4a3e5f62
commit 3754abe694
11 changed files with 3698 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
node_modules/
dist/
+2292
View File
File diff suppressed because it is too large Load Diff
+20
View File
@@ -0,0 +1,20 @@
{
"name": "kotej-web",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"preview": "vite preview",
"test": "vitest run"
},
"devDependencies": {
"@types/jsdom": "^28.0.3",
"fflate": "^0.8.2",
"jsdom": "^25.0.0",
"typescript": "^5.6.0",
"vite": "^5.4.0",
"vitest": "^2.1.0"
}
}
+220
View File
@@ -0,0 +1,220 @@
/**
* Deciding whether two blobs are equal, and under which rules. Ported from the
* desktop engine so both give the same verdict; the browser supplies the two
* hardest parts for free — DOMParser and JSON.parse.
*/
export type ComparisonMode = 'binary' | 'text' | 'semantic';
export type ContentKind = 'json' | 'xml' | 'text' | 'binary';
export type CompareResult = 'equal' | 'equivalent' | 'different' | 'unsupported';
export interface TextOptions {
ignoreTrailingWhitespace: boolean;
ignoreAllWhitespace: boolean;
ignoreCase: boolean;
ignoreBlankLines: boolean;
/** Treat CRLF and LF as the same, so files that crossed platforms still match. */
normaliseLineEndings: boolean;
}
export const defaultTextOptions: TextOptions = {
ignoreTrailingWhitespace: true,
ignoreAllWhitespace: false,
ignoreCase: false,
ignoreBlankLines: false,
normaliseLineEndings: true,
};
const XML_EXTENSIONS = new Set([
'xml', 'xsd', 'xsl', 'xslt', 'wsdl', 'pom', 'svg', 'plist', 'storyboard',
]);
const TEXT_EXTENSIONS = new Set([
'txt', 'md', 'yml', 'yaml', 'properties', 'csv', 'log', 'java', 'swift', 'go',
'js', 'ts', 'sh', 'sql', 'html', 'css',
]);
/** Guesses from the extension first (cheap) and falls back to sniffing. */
export function detectKind(path: string, data?: Uint8Array): ContentKind {
const dot = path.lastIndexOf('.');
const ext = dot > 0 ? path.slice(dot + 1).toLowerCase() : '';
if (ext === 'json') return 'json';
if (XML_EXTENSIONS.has(ext)) return 'xml';
if (TEXT_EXTENSIONS.has(ext)) return 'text';
if (!data) return 'binary';
return sniff(data);
}
/** A NUL byte in the first block is the classic "this is binary" signal. */
export function sniff(data: Uint8Array): ContentKind {
const sample = data.subarray(0, 8000);
if (sample.includes(0)) return 'binary';
let text: string;
try {
text = new TextDecoder('utf-8', { fatal: true }).decode(sample);
} catch {
return 'binary';
}
const trimmed = text.trim();
if (trimmed.startsWith('{') || trimmed.startsWith('[')) return 'json';
if (trimmed.startsWith('<')) return 'xml';
return 'text';
}
/** The comparison that makes sense by default for this kind. */
export function defaultMode(kind: ContentKind): ComparisonMode {
if (kind === 'json' || kind === 'xml') return 'semantic';
if (kind === 'text') return 'text';
return 'binary';
}
export function bytesEqual(a: Uint8Array, b: Uint8Array): boolean {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
return true;
}
const decoder = new TextDecoder();
export function compare(left: Uint8Array, right: Uint8Array, mode: ComparisonMode,
kind: ContentKind,
textOptions: TextOptions = defaultTextOptions): CompareResult {
if (bytesEqual(left, right)) return 'equal';
switch (mode) {
case 'binary':
return 'different';
case 'text':
return normaliseText(decoder.decode(left), textOptions)
=== normaliseText(decoder.decode(right), textOptions) ? 'equivalent' : 'different';
case 'semantic':
if (kind === 'json') return compareJSON(left, right);
if (kind === 'xml') return compareXML(left, right);
if (kind === 'text') return compare(left, right, 'text', 'text', textOptions);
return 'different';
}
}
// MARK: Text
export function normaliseText(text: string, options: TextOptions): string {
let value = text;
if (options.normaliseLineEndings) value = value.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
if (options.ignoreCase) value = value.toLowerCase();
let lines = value.split('\n');
if (options.ignoreAllWhitespace) {
lines = lines.map((line) => line.replace(/\s/g, ''));
} else if (options.ignoreTrailingWhitespace) {
lines = lines.map((line) => line.replace(/\s+$/, ''));
}
if (options.ignoreBlankLines) {
lines = lines.filter((line) => line !== '');
} else {
// A missing final newline shouldn't count as a difference on its own.
while (lines.length && lines[lines.length - 1] === '') lines.pop();
}
return lines.join('\n');
}
// MARK: JSON
export function compareJSON(left: Uint8Array, right: Uint8Array): CompareResult {
let leftValue: unknown;
let rightValue: unknown;
try {
leftValue = JSON.parse(decoder.decode(left));
} catch {
return 'unsupported';
}
try {
rightValue = JSON.parse(decoder.decode(right));
} catch {
return 'unsupported';
}
return jsonEqual(leftValue, rightValue) ? 'equivalent' : 'different';
}
/**
* Structural equality: object key order and formatting are irrelevant, but array
* order is significant, and true is not 1.
*/
export function jsonEqual(left: unknown, right: unknown): boolean {
if (left === null || right === null) return left === right;
if (typeof left !== typeof right) return false;
if (Array.isArray(left) || Array.isArray(right)) {
if (!Array.isArray(left) || !Array.isArray(right)) return false;
if (left.length !== right.length) return false;
return left.every((item, index) => jsonEqual(item, right[index]));
}
if (typeof left === 'object') {
const l = left as Record<string, unknown>;
const r = right as Record<string, unknown>;
const leftKeys = Object.keys(l);
if (leftKeys.length !== Object.keys(r).length) return false;
return leftKeys.every((key) =>
Object.prototype.hasOwnProperty.call(r, key) && jsonEqual(l[key], r[key]));
}
return left === right;
}
// MARK: XML
export function compareXML(left: Uint8Array, right: Uint8Array): CompareResult {
const leftRoot = parseXML(decoder.decode(left));
if (!leftRoot) return 'unsupported';
const rightRoot = parseXML(decoder.decode(right));
if (!rightRoot) return 'unsupported';
return canonicalXML(leftRoot) === canonicalXML(rightRoot) ? 'equivalent' : 'different';
}
function parseXML(text: string): Element | null {
const doc = new DOMParser().parseFromString(text, 'application/xml');
if (doc.getElementsByTagName('parsererror').length > 0) return null;
return doc.documentElement ?? null;
}
/**
* A normalised rendering, built so two documents that mean the same thing render
* identically. Attribute order, insignificant whitespace and the order of
* *repeated* siblings don't matter; the order of differently named siblings does,
* since a sequence of distinct elements can carry meaning.
*
* Comparing canonical strings also avoids matching children pairwise, which would
* be quadratic on exactly the long repeated lists this is for.
*/
export function canonicalXML(element: Element): string {
let out = `<${element.namespaceURI ?? ''}|${element.localName}`;
const attributes: string[] = [];
for (const attribute of Array.from(element.attributes)) {
// Namespace declarations are structure, not content.
if (attribute.name.startsWith('xmlns')) continue;
attributes.push(`${attribute.name}=${attribute.value}`);
}
out += ` ${attributes.sort().join(' ')}>`;
const children = Array.from(element.children);
if (children.length === 0) {
return `${out}${(element.textContent ?? '').trim()}</>`;
}
// Group siblings by name, keeping the order names first appear.
const order: string[] = [];
const groups = new Map<string, Element[]>();
for (const child of children) {
const key = `${child.namespaceURI ?? ''}|${child.localName}`;
if (!groups.has(key)) {
groups.set(key, []);
order.push(key);
}
groups.get(key)!.push(child);
}
for (const key of order) {
const rendered = groups.get(key)!.map(canonicalXML);
// Repeated entries are a set: sorting makes their order irrelevant.
out += rendered.length > 1 ? rendered.sort().join('') : rendered.join('');
}
return `${out}</>`;
}
+237
View File
@@ -0,0 +1,237 @@
/**
* Line-by-line alignment for the side-by-side view, plus telling apart what kind
* of change a line actually holds. Ported from the desktop engine.
*/
export type RowKind = 'equal' | 'changed' | 'added' | 'removed';
/** What a changed line differs in — enough to skip the cosmetic ones. */
export type ChangeKind = 'whitespace' | 'comment' | 'content';
export interface DiffRow {
kind: RowKind;
leftNumber: number | null;
rightNumber: number | null;
left: string;
right: string;
changeKind: ChangeKind;
/** Character range that actually differs, so the exact characters can be marked. */
leftHighlight: [number, number] | null;
rightHighlight: [number, number] | null;
}
export interface CommentSyntax {
linePrefixes: string[];
blockOpen?: string;
blockClose?: string;
}
export const C_LIKE: CommentSyntax = { linePrefixes: ['//'], blockOpen: '/*', blockClose: '*/' };
export const HASH: CommentSyntax = { linePrefixes: ['#'] };
export const SQL: CommentSyntax = { linePrefixes: ['--'], blockOpen: '/*', blockClose: '*/' };
export const MARKUP: CommentSyntax = { linePrefixes: [], blockOpen: '<!--', blockClose: '-->' };
/**
* Best guess from the extension. Unknown types get null, and then a change is
* never reported as comment-only — better to under-claim than to hide a real one.
*/
export function commentSyntaxFor(path: string): CommentSyntax | null {
const dot = path.lastIndexOf('.');
const ext = dot > 0 ? path.slice(dot + 1).toLowerCase() : '';
if (['swift', 'java', 'js', 'ts', 'jsx', 'tsx', 'go', 'c', 'h', 'cpp', 'hpp', 'cs',
'kt', 'scala', 'rs', 'css', 'json5', 'gradle', 'groovy'].includes(ext)) return C_LIKE;
if (['py', 'rb', 'sh', 'bash', 'zsh', 'yml', 'yaml', 'properties', 'conf', 'cfg',
'ini', 'toml', 'dockerfile', 'makefile', 'pl', 'r'].includes(ext)) return HASH;
if (ext === 'sql') return SQL;
if (['xml', 'html', 'htm', 'xsd', 'xsl', 'xslt', 'wsdl', 'svg', 'vue'].includes(ext)) return MARKUP;
return null;
}
/** The line with its comments removed; block comments only when they close on it. */
export function stripComments(line: string, syntax: CommentSyntax): string {
let result = line;
const { blockOpen, blockClose } = syntax;
if (blockOpen && blockClose) {
for (;;) {
const start = result.indexOf(blockOpen);
if (start < 0) break;
const end = result.indexOf(blockClose, start + blockOpen.length);
if (end < 0) break;
result = result.slice(0, start) + result.slice(end + blockClose.length);
}
}
for (const prefix of syntax.linePrefixes) {
const at = result.indexOf(prefix);
if (at >= 0) {
result = result.slice(0, at);
break;
}
}
return result;
}
/** The differing middle of two lines, after trimming what they share at each end. */
export function inlineRanges(left: string, right: string):
[[number, number] | null, [number, number] | null] {
if (!left.length || !right.length) return [null, null];
let prefix = 0;
while (prefix < left.length && prefix < right.length && left[prefix] === right[prefix]) prefix++;
let suffix = 0;
while (suffix < left.length - prefix && suffix < right.length - prefix
&& left[left.length - 1 - suffix] === right[right.length - 1 - suffix]) suffix++;
const leftRange: [number, number] = [prefix, left.length - suffix];
const rightRange: [number, number] = [prefix, right.length - suffix];
if (leftRange[0] >= leftRange[1] && rightRange[0] >= rightRange[1]) return [null, null];
return [leftRange, rightRange];
}
/** Classifies a changed pair and locates the differing characters. */
export function describe(left: string, right: string, syntax: CommentSyntax | null) {
const [leftRange, rightRange] = inlineRanges(left, right);
// Whitespace first: it subsumes the others when nothing else moved.
if (left.replace(/\s/g, '') === right.replace(/\s/g, '')) {
return { changeKind: 'whitespace' as ChangeKind, leftRange, rightRange };
}
if (syntax) {
if (stripComments(left, syntax).trim() === stripComments(right, syntax).trim()) {
return { changeKind: 'comment' as ChangeKind, leftRange, rightRange };
}
}
return { changeKind: 'content' as ChangeKind, leftRange, rightRange };
}
function row(kind: RowKind, leftNumber: number | null, rightNumber: number | null,
left: string, right: string, syntax: CommentSyntax | null = null): DiffRow {
if (kind !== 'changed') {
return { kind, leftNumber, rightNumber, left, right,
changeKind: 'content', leftHighlight: null, rightHighlight: null };
}
const { changeKind, leftRange, rightRange } = describe(left, right, syntax);
return { kind, leftNumber, rightNumber, left, right,
changeKind, leftHighlight: leftRange, rightHighlight: rightRange };
}
/**
* Aligns both texts. Common prefixes and suffixes are matched cheaply first, so a
* one-line change in a big file costs almost nothing; only the middle needs the
* quadratic pass, and beyond `limit` lines that middle is paired positionally
* rather than hanging the UI.
*/
export function diffRows(left: string, right: string,
syntax: CommentSyntax | null = null, limit = 3000): DiffRow[] {
const leftLines = split(left);
const rightLines = split(right);
let prefix = 0;
while (prefix < leftLines.length && prefix < rightLines.length
&& leftLines[prefix] === rightLines[prefix]) prefix++;
let suffix = 0;
while (suffix < leftLines.length - prefix && suffix < rightLines.length - prefix
&& leftLines[leftLines.length - 1 - suffix] === rightLines[rightLines.length - 1 - suffix]) {
suffix++;
}
const rows: DiffRow[] = [];
for (let i = 0; i < prefix; i++) {
rows.push(row('equal', i + 1, i + 1, leftLines[i], rightLines[i]));
}
const leftMiddle = leftLines.slice(prefix, leftLines.length - suffix);
const rightMiddle = rightLines.slice(prefix, rightLines.length - suffix);
rows.push(...(leftMiddle.length > limit || rightMiddle.length > limit
? blockRows(leftMiddle, rightMiddle, prefix, prefix, syntax)
: align(leftMiddle, rightMiddle, prefix, prefix, syntax)));
for (let i = 0; i < suffix; i++) {
const leftIndex = leftLines.length - suffix + i;
const rightIndex = rightLines.length - suffix + i;
rows.push(row('equal', leftIndex + 1, rightIndex + 1,
leftLines[leftIndex], rightLines[rightIndex]));
}
return rows;
}
function split(text: string): string[] {
return text.replace(/\r\n/g, '\n').split('\n');
}
/** Longest common subsequence over the differing middle section. */
function align(left: string[], right: string[], leftStart: number, rightStart: number,
syntax: CommentSyntax | null): DiffRow[] {
if (!left.length && !right.length) return [];
// lengths[i][j] = LCS length of left[i...] and right[j...]
const lengths: number[][] = Array.from({ length: left.length + 1 },
() => new Array<number>(right.length + 1).fill(0));
for (let i = left.length - 1; i >= 0; i--) {
for (let j = right.length - 1; j >= 0; j--) {
lengths[i][j] = left[i] === right[j]
? lengths[i + 1][j + 1] + 1
: Math.max(lengths[i + 1][j], lengths[i][j + 1]);
}
}
const rows: DiffRow[] = [];
let i = 0, j = 0;
// Pending one-sided runs are paired up as "changed" so a modified line shows
// opposite its old version instead of as a delete plus an insert.
let removed: Array<[number, string]> = [];
let added: Array<[number, string]> = [];
const flush = () => {
const shared = Math.min(removed.length, added.length);
for (let k = 0; k < shared; k++) {
rows.push(row('changed', removed[k][0], added[k][0], removed[k][1], added[k][1], syntax));
}
for (let k = shared; k < removed.length; k++) {
rows.push(row('removed', removed[k][0], null, removed[k][1], ''));
}
for (let k = shared; k < added.length; k++) {
rows.push(row('added', null, added[k][0], '', added[k][1]));
}
removed = [];
added = [];
};
while (i < left.length && j < right.length) {
if (left[i] === right[j]) {
flush();
rows.push(row('equal', leftStart + i + 1, rightStart + j + 1, left[i], right[j]));
i++; j++;
} else if (lengths[i + 1][j] >= lengths[i][j + 1]) {
removed.push([leftStart + i + 1, left[i]]); i++;
} else {
added.push([rightStart + j + 1, right[j]]); j++;
}
}
while (i < left.length) { removed.push([leftStart + i + 1, left[i]]); i++; }
while (j < right.length) { added.push([rightStart + j + 1, right[j]]); j++; }
flush();
return rows;
}
/** Fallback for very large differing sections: pair the lines positionally. */
function blockRows(left: string[], right: string[], leftStart: number, rightStart: number,
syntax: CommentSyntax | null): DiffRow[] {
const rows: DiffRow[] = [];
for (let index = 0; index < Math.max(left.length, right.length); index++) {
const l = index < left.length ? left[index] : null;
const r = index < right.length ? right[index] : null;
if (l !== null && r !== null) {
rows.push(l === r
? row('equal', leftStart + index + 1, rightStart + index + 1, l, r)
: row('changed', leftStart + index + 1, rightStart + index + 1, l, r, syntax));
} else if (l !== null) {
rows.push(row('removed', leftStart + index + 1, null, l, ''));
} else if (r !== null) {
rows.push(row('added', null, rightStart + index + 1, '', r));
}
}
return rows;
}
+313
View File
@@ -0,0 +1,313 @@
/**
* Building comparison trees from folders and archives, and pairing two of them.
*
* Everything stays client-side: files are read from `File` handles the user
* picked, never uploaded. Archives are browsed in place — listing one only reads
* the tail of the file, which is what keeps a large EAR usable in a browser.
*/
import {
ZipReader, type ZipEntry, isArchive, mayBeNestedArchive, looksLikeArchive,
} from './zip';
import {
compare as compareContent, defaultMode, detectKind, defaultTextOptions,
type ComparisonMode, type TextOptions,
} from './content';
/** Where a node's bytes come from. */
export type NodeSource =
| { kind: 'file'; file: File }
| { kind: 'entry'; reader: ZipReader; entry: ZipEntry }
| { kind: 'folder' };
export interface Node {
name: string;
/** Path relative to the root of its side, used to pair the two trees. */
path: string;
isDirectory: boolean;
size: number;
/** CRC32 from the archive's central directory: a content fingerprint for free. */
crc32?: number;
/** True when this folder is really an archive shown as a folder. */
isArchive: boolean;
source: NodeSource;
children: Node[];
}
export async function nodeData(node: Node): Promise<Uint8Array> {
if (node.source.kind === 'file') {
return new Uint8Array(await node.source.file.arrayBuffer());
}
if (node.source.kind === 'entry') {
return node.source.reader.data(node.source.entry);
}
throw new Error('folders have no contents of their own');
}
function sortChildren(node: Node) {
node.children.sort((a, b) => {
if (a.isDirectory !== b.isDirectory) return a.isDirectory ? -1 : 1;
return a.name.localeCompare(b.name, undefined, { numeric: true });
});
node.children.forEach(sortChildren);
}
/** Scans a single file, opening it as a folder when it turns out to be an archive. */
export async function scanFile(file: File, path = file.name): Promise<Node> {
if (await isArchive(file, file.name)) {
try {
return await scanArchive(file, path);
} catch {
// Not really readable as an archive: fall through and treat it as a file.
}
}
return {
name: file.name, path, isDirectory: false, size: file.size,
isArchive: false, source: { kind: 'file', file }, children: [],
};
}
/**
* Builds a tree from files carrying a relative path, which is what both the
* directory picker and a dropped folder give us.
*/
export async function scanFiles(files: Array<{ file: File; relativePath: string }>,
rootName: string): Promise<Node> {
const root: Node = {
name: rootName, path: '', isDirectory: true, size: 0,
isArchive: false, source: { kind: 'folder' }, children: [],
};
const folders = new Map<string, Node>([['', root]]);
const folderFor = (path: string): Node => {
const existing = folders.get(path);
if (existing) return existing;
const parentPath = path.includes('/') ? path.slice(0, path.lastIndexOf('/')) : '';
const parent = folderFor(parentPath);
const node: Node = {
name: path.slice(path.lastIndexOf('/') + 1), path, isDirectory: true, size: 0,
isArchive: false, source: { kind: 'folder' }, children: [],
};
parent.children.push(node);
folders.set(path, node);
return node;
};
for (const { file, relativePath } of files) {
const parentPath = relativePath.includes('/')
? relativePath.slice(0, relativePath.lastIndexOf('/')) : '';
folderFor(parentPath).children.push(await scanFile(file, relativePath));
}
sortChildren(root);
return root;
}
/** Turns an archive's flat entry list into a tree of folders and files. */
export async function scanArchive(blob: Blob, rootPath: string,
name = rootPath.split('/').pop() ?? rootPath): Promise<Node> {
const reader = await ZipReader.open(blob);
const root: Node = {
name, path: rootPath, isDirectory: true, size: blob.size,
isArchive: true, source: { kind: 'folder' }, children: [],
};
const folders = new Map<string, Node>([['', root]]);
const folderFor = (path: string): Node => {
const existing = folders.get(path);
if (existing) return existing;
const parentPath = path.includes('/') ? path.slice(0, path.lastIndexOf('/')) : '';
const parent = folderFor(parentPath);
const node: Node = {
name: path.slice(path.lastIndexOf('/') + 1), path: `${rootPath}/${path}`,
isDirectory: true, size: 0, isArchive: false,
source: { kind: 'folder' }, children: [],
};
parent.children.push(node);
folders.set(path, node);
return node;
};
for (const entry of reader.entries) {
if (entry.isDirectory) continue;
const parentPath = entry.path.includes('/')
? entry.path.slice(0, entry.path.lastIndexOf('/')) : '';
const entryName = entry.path.slice(entry.path.lastIndexOf('/') + 1);
const childPath = `${rootPath}/${entry.path}`;
const parent = folderFor(parentPath);
// A JAR inside an EAR, or any renamed ZIP: open it as a folder too.
if (mayBeNestedArchive(entryName, entry.uncompressedSize)) {
try {
const bytes = await reader.data(entry);
if (looksLikeArchive(bytes.subarray(0, 4))) {
const blob = new Blob([bytes.slice().buffer as ArrayBuffer]);
parent.children.push(await scanArchive(blob, childPath, entryName));
continue;
}
} catch {
// Unreadable as an archive: fall through and treat it as a plain entry.
}
}
parent.children.push({
name: entryName, path: childPath, isDirectory: false,
size: entry.uncompressedSize, crc32: entry.crc32, isArchive: false,
source: { kind: 'entry', reader, entry }, children: [],
});
}
sortChildren(root);
return root;
}
// MARK: Comparing
export type DiffStatus =
| 'identical' | 'equivalent' | 'different' | 'onlyLeft' | 'onlyRight' | 'pending';
export function isDifference(status: DiffStatus): boolean {
return status !== 'identical' && status !== 'equivalent';
}
export interface DiffNode {
name: string;
path: string;
isDirectory: boolean;
isArchive: boolean;
left: Node | null;
right: Node | null;
status: DiffStatus;
/** Which comparison settled this file, so "Automatic" can show what it chose. */
appliedMode?: ComparisonMode;
children: DiffNode[];
}
export interface CompareOptions {
mode?: ComparisonMode;
textOptions?: TextOptions;
/** Files bigger than this are left pending and resolved on demand. */
eagerSizeLimit?: number;
}
export interface Totals {
identical: number; different: number; onlyLeft: number; onlyRight: number; pending: number;
}
export function totals(node: DiffNode): Totals {
const out: Totals = { identical: 0, different: 0, onlyLeft: 0, onlyRight: 0, pending: 0 };
const walk = (n: DiffNode) => {
if (n.isDirectory) { n.children.forEach(walk); return; }
if (n.status === 'identical' || n.status === 'equivalent') out.identical++;
else if (n.status === 'different') out.different++;
else if (n.status === 'onlyLeft') out.onlyLeft++;
else if (n.status === 'onlyRight') out.onlyRight++;
else out.pending++;
};
walk(node);
return out;
}
/**
* Pairs two trees. Layered so large trees stay fast: sizes and CRC32s prove
* equality without reading a byte. Note the asymmetry — the cheap checks can
* prove two files *identical* but never that they *differ*, because JSON or XML
* with other bytes may still be equivalent.
*/
export async function compareTrees(left: Node, right: Node,
options: CompareOptions = {}): Promise<DiffNode> {
return pair(left, right, left.name, '', options);
}
async function pair(left: Node | null, right: Node | null, name: string, path: string,
options: CompareOptions): Promise<DiffNode> {
if (!left || !right) {
const present = (left ?? right)!;
const node: DiffNode = {
name, path, isDirectory: present.isDirectory, isArchive: present.isArchive,
left, right, status: left ? 'onlyLeft' : 'onlyRight', children: [],
};
node.children = await Promise.all(present.children.map((child) =>
pair(left ? child : null, left ? null : child, child.name, child.path, options)));
return node;
}
if (left.isDirectory && right.isDirectory) {
const node: DiffNode = {
name, path, isDirectory: true, isArchive: left.isArchive || right.isArchive,
left, right, status: 'identical', children: [],
};
node.children = await pairChildren(left, right, options);
// A folder differs when anything inside it does.
node.status = node.children.some((child) => isDifference(child.status)) ? 'different' : 'identical';
return node;
}
if (left.isDirectory !== right.isDirectory) {
return {
name, path, isDirectory: left.isDirectory, isArchive: left.isArchive || right.isArchive,
left, right, status: 'different', children: [],
};
}
const outcome = await fileOutcome(left, right, options);
return {
name, path, isDirectory: false, isArchive: false, left, right,
status: outcome.status, appliedMode: outcome.mode, children: [],
};
}
async function pairChildren(left: Node, right: Node, options: CompareOptions): Promise<DiffNode[]> {
const rightByName = new Map(right.children.map((child) => [child.name, child]));
const rows: DiffNode[] = [];
for (const leftChild of left.children) {
const match = rightByName.get(leftChild.name) ?? null;
rightByName.delete(leftChild.name);
rows.push(await pair(leftChild, match, leftChild.name, leftChild.path, options));
}
for (const orphan of rightByName.values()) {
rows.push(await pair(null, orphan, orphan.name, orphan.path, options));
}
rows.sort((a, b) => {
if (a.isDirectory !== b.isDirectory) return a.isDirectory ? -1 : 1;
return a.name.localeCompare(b.name, undefined, { numeric: true });
});
return rows;
}
/** The status plus which comparison produced it. */
async function fileOutcome(left: Node, right: Node, options: CompareOptions) {
// Level 1 — CRC32 straight from the archive directory: free and decisive.
if (left.crc32 !== undefined && right.crc32 !== undefined) {
if (left.crc32 === right.crc32 && left.size === right.size) {
return { status: 'identical' as DiffStatus, mode: undefined };
}
} else if (left.size === right.size && left.size === 0) {
return { status: 'identical' as DiffStatus, mode: undefined };
}
const limit = options.eagerSizeLimit ?? 4 * 1024 * 1024;
if (Math.max(left.size, right.size) > limit) {
return { status: 'pending' as DiffStatus, mode: undefined };
}
return resolve(left, right, options);
}
/** Reads both sides and applies the chosen (or inferred) comparison mode. */
export async function resolve(left: Node, right: Node, options: CompareOptions = {}) {
let leftData: Uint8Array;
let rightData: Uint8Array;
try {
[leftData, rightData] = await Promise.all([nodeData(left), nodeData(right)]);
} catch {
return { status: 'different' as DiffStatus, mode: undefined };
}
const kind = detectKind(left.name, leftData);
const mode = options.mode ?? defaultMode(kind);
const result = compareContent(leftData, rightData, mode, kind,
options.textOptions ?? defaultTextOptions);
const status: DiffStatus =
result === 'equal' ? 'identical' : result === 'equivalent' ? 'equivalent' : 'different';
return { status, mode };
}
+219
View File
@@ -0,0 +1,219 @@
/**
* Reads ZIP containers (JAR, EAR, WAR too) straight from a File, in the browser.
*
* Same design as the desktop engine: the central directory already records every
* entry's CRC32 and sizes, so two archives can be compared entry by entry without
* decompressing anything. Only the tail of the file is read to list an archive —
* which is what keeps a 500 MB EAR usable client-side.
*/
export interface ZipEntry {
path: string;
crc32: number;
compressedSize: number;
uncompressedSize: number;
compressionMethod: number;
localHeaderOffset: number;
isDirectory: boolean;
}
const EOCD_SIGNATURE = 0x06054b50;
const ZIP64_LOCATOR_SIGNATURE = 0x07064b50;
const ZIP64_END_SIGNATURE = 0x06064b50;
const CENTRAL_HEADER_SIGNATURE = 0x02014b50;
const LOCAL_HEADER_SIGNATURE = 0x04034b50;
/** Extensions treated as containers without looking at the bytes. */
export const ARCHIVE_EXTENSIONS = new Set([
'zip', 'jar', 'ear', 'war', 'aar', 'ipa', 'sar', 'par',
]);
/**
* Extensions that are definitely not containers, so the thousands of small
* entries inside a real EAR aren't inflated just to check for a signature.
*/
const NEVER_ARCHIVE_EXTENSIONS = new Set([
'class', 'java', 'swift', 'kt', 'go', 'c', 'h', 'm', 'js', 'ts', 'css', 'scss',
'html', 'htm', 'xml', 'xsd', 'xsl', 'xslt', 'wsdl', 'json', 'yml', 'yaml',
'properties', 'conf', 'cfg', 'ini', 'toml', 'md', 'txt', 'log', 'csv', 'sql',
'sh', 'bat', 'mf', 'sf', 'rsa', 'dsa', 'png', 'jpg', 'jpeg', 'gif', 'svg',
'ico', 'pdf', 'ttf', 'otf', 'woff', 'woff2', 'so', 'dylib', 'plist',
]);
export function extensionOf(name: string): string {
const dot = name.lastIndexOf('.');
return dot <= 0 ? '' : name.slice(dot + 1).toLowerCase();
}
export function hasArchiveExtension(name: string): boolean {
return ARCHIVE_EXTENSIONS.has(extensionOf(name));
}
/** The local-file-header signature, plus the empty and spanned variants. */
export function looksLikeArchive(head: Uint8Array): boolean {
if (head.length < 4) return false;
const [a, b, c, d] = head;
if (a !== 0x50 || b !== 0x4b) return false;
return (c === 0x03 && d === 0x04) || (c === 0x05 && d === 0x06) || (c === 0x07 && d === 0x08);
}
/** A ZIP renamed to anything else is still a ZIP, so fall back to the signature. */
export async function isArchive(blob: Blob, name: string): Promise<boolean> {
if (hasArchiveExtension(name)) return true;
if (blob.size < 22) return false;
const head = new Uint8Array(await blob.slice(0, 4).arrayBuffer());
return looksLikeArchive(head);
}
/**
* Whether an entry inside an archive is worth opening as one. Known extensions
* always are; clearly non-container ones never are; the rest are only inflated
* when small enough that guessing wrong is cheap.
*/
export function mayBeNestedArchive(name: string, uncompressedSize: number,
inflateLimit = 64 * 1024 * 1024): boolean {
const ext = extensionOf(name);
if (ARCHIVE_EXTENSIONS.has(ext)) return true;
if (ext && NEVER_ARCHIVE_EXTENSIONS.has(ext)) return false;
return uncompressedSize >= 22 && uncompressedSize <= inflateLimit;
}
export class ZipError extends Error {}
/** Reads a ZIP's central directory, touching only the bytes it needs. */
export class ZipReader {
private constructor(readonly blob: Blob, readonly entries: ZipEntry[]) {}
static async open(blob: Blob): Promise<ZipReader> {
return new ZipReader(blob, await readCentralDirectory(blob));
}
/** Inflates one entry. Comparisons normally stop at the CRC32 instead. */
async data(entry: ZipEntry): Promise<Uint8Array> {
const headerSlice = new DataView(
await this.blob.slice(entry.localHeaderOffset, entry.localHeaderOffset + 30).arrayBuffer());
if (headerSlice.getUint32(0, true) !== LOCAL_HEADER_SIGNATURE) {
throw new ZipError(`bad local header for ${entry.path}`);
}
const nameLength = headerSlice.getUint16(26, true);
const extraLength = headerSlice.getUint16(28, true);
const start = entry.localHeaderOffset + 30 + nameLength + extraLength;
const payload = this.blob.slice(start, start + entry.compressedSize);
if (entry.compressionMethod === 0) {
return new Uint8Array(await payload.arrayBuffer());
}
if (entry.compressionMethod !== 8) {
throw new ZipError(`unsupported compression method ${entry.compressionMethod}`);
}
// Raw DEFLATE, decompressed by the browser itself — no bundled zlib.
const stream = payload.stream().pipeThrough(new DecompressionStream('deflate-raw'));
return new Uint8Array(await new Response(stream).arrayBuffer());
}
}
async function readCentralDirectory(blob: Blob): Promise<ZipEntry[]> {
const eocd = await findEndOfCentralDirectory(blob);
if (!eocd) throw new ZipError('Not a ZIP archive (no end-of-central-directory record).');
let entryCount = eocd.view.getUint16(eocd.offset + 10, true);
let directoryOffset = eocd.view.getUint32(eocd.offset + 16, true);
const directorySize = eocd.view.getUint32(eocd.offset + 12, true);
// ZIP64: the 32-bit fields saturate and the real values live elsewhere.
if (directoryOffset === 0xffffffff || entryCount === 0xffff || directorySize === 0xffffffff) {
const locatorOffset = eocd.offset - 20;
if (locatorOffset < 0 || eocd.view.getUint32(locatorOffset, true) !== ZIP64_LOCATOR_SIGNATURE) {
throw new ZipError('ZIP64 without locator');
}
const zip64End = Number(eocd.view.getBigUint64(locatorOffset + 8, true));
const record = new DataView(await blob.slice(zip64End, zip64End + 56).arrayBuffer());
if (record.getUint32(0, true) !== ZIP64_END_SIGNATURE) throw new ZipError('bad ZIP64 end record');
entryCount = Number(record.getBigUint64(32, true));
directoryOffset = Number(record.getBigUint64(48, true));
}
const directory = new Uint8Array(await blob.slice(directoryOffset).arrayBuffer());
const view = new DataView(directory.buffer);
const decoder = new TextDecoder();
const entries: ZipEntry[] = [];
let offset = 0;
for (let i = 0; i < entryCount; i++) {
if (offset + 46 > directory.length || view.getUint32(offset, true) !== CENTRAL_HEADER_SIGNATURE) {
throw new ZipError('bad central directory header');
}
const method = view.getUint16(offset + 10, true);
const crc = view.getUint32(offset + 16, true);
let compressed = view.getUint32(offset + 20, true);
let uncompressed = view.getUint32(offset + 24, true);
const nameLength = view.getUint16(offset + 28, true);
const extraLength = view.getUint16(offset + 30, true);
const commentLength = view.getUint16(offset + 32, true);
let localOffset = view.getUint32(offset + 42, true);
const nameStart = offset + 46;
const rawName = decoder.decode(directory.subarray(nameStart, nameStart + nameLength));
if (uncompressed === 0xffffffff || compressed === 0xffffffff || localOffset === 0xffffffff) {
({ uncompressed, compressed, localOffset } = readZip64Extra(
view, nameStart + nameLength, extraLength, uncompressed, compressed, localOffset));
}
entries.push({
path: normalise(rawName),
crc32: crc,
compressedSize: compressed,
uncompressedSize: uncompressed,
compressionMethod: method,
localHeaderOffset: localOffset,
isDirectory: rawName.endsWith('/'),
});
offset = nameStart + nameLength + extraLength + commentLength;
}
return entries;
}
/** Entries may use either separator and some tools prefix "./". */
function normalise(name: string): string {
let path = name.replace(/\\/g, '/');
while (path.startsWith('./')) path = path.slice(2);
while (path.endsWith('/')) path = path.slice(0, -1);
return path;
}
async function findEndOfCentralDirectory(blob: Blob) {
const minimum = 22;
if (blob.size < minimum) return null;
// The record sits at the end, possibly followed by a comment (max 64 KiB).
const tailLength = Math.min(blob.size, minimum + 0xffff);
const tail = new DataView(await blob.slice(blob.size - tailLength).arrayBuffer());
const base = blob.size - tailLength;
for (let offset = tail.byteLength - minimum; offset >= 0; offset--) {
if (tail.getUint32(offset, true) === EOCD_SIGNATURE) {
// Re-read from the EOCD so offsets in the record are absolute-friendly.
return { view: tail, offset, base };
}
}
return null;
}
function readZip64Extra(view: DataView, start: number, length: number,
uncompressed: number, compressed: number, localOffset: number) {
let cursor = start;
const end = start + length;
while (cursor + 4 <= end) {
const headerID = view.getUint16(cursor, true);
const size = view.getUint16(cursor + 2, true);
let field = cursor + 4;
if (headerID === 0x0001) {
if (uncompressed === 0xffffffff) { uncompressed = Number(view.getBigUint64(field, true)); field += 8; }
if (compressed === 0xffffffff) { compressed = Number(view.getBigUint64(field, true)); field += 8; }
if (localOffset === 0xffffffff) { localOffset = Number(view.getBigUint64(field, true)); }
break;
}
cursor = field + size;
}
return { uncompressed, compressed, localOffset };
}
+364
View File
@@ -0,0 +1,364 @@
/**
* Ported from the desktop engine's tests. They exist to prove the web version
* gives the *same verdicts* as the Mac app — that's the whole point of having two
* implementations, so any divergence has to show up here.
*/
import { describe, it, expect } from 'vitest';
import { zipSync } from 'fflate';
import {
compare, detectKind, defaultMode, jsonEqual, normaliseText, defaultTextOptions,
canonicalXML,
} from '../src/engine/content';
import { diffRows, inlineRanges, describe as describeChange, commentSyntaxFor, C_LIKE, HASH } from '../src/engine/diff';
import {
ZipReader, isArchive, mayBeNestedArchive, hasArchiveExtension, looksLikeArchive,
} from '../src/engine/zip';
import { scanArchive, scanFiles, compareTrees, totals, type DiffNode } from '../src/engine/tree';
const bytes = (text: string) => new TextEncoder().encode(text);
function makeZip(files: Record<string, string>): Blob {
const entries: Record<string, Uint8Array> = {};
for (const [path, body] of Object.entries(files)) entries[path] = bytes(body);
return new Blob([zipSync(entries)]);
}
function fileFor(path: string, body: string) {
return { file: new File([bytes(body)], path.split('/').pop()!), relativePath: path };
}
function row(node: DiffNode, path: string): DiffNode | undefined {
if (node.path === path) return node;
for (const child of node.children) {
const found = row(child, path);
if (found) return found;
}
return undefined;
}
// MARK: JSON
describe('JSON', () => {
it('ignores key order and formatting', () => {
const left = bytes('{"a":1,"b":{"c":true,"d":null}}');
const right = bytes('{\n "b": { "d": null, "c": true },\n "a": 1\n}');
expect(compare(left, right, 'semantic', 'json')).toBe('equivalent');
expect(compare(left, right, 'binary', 'json')).toBe('different');
});
it('keeps array order significant', () => {
expect(compare(bytes('{"i":[1,2,3]}'), bytes('{"i":[3,2,1]}'), 'semantic', 'json'))
.toBe('different');
});
it('does not treat true as 1', () => {
expect(jsonEqual(true, 1)).toBe(false);
expect(compare(bytes('{"f":true}'), bytes('{"f":1}'), 'semantic', 'json')).toBe('different');
});
it('reports invalid JSON instead of calling it equal', () => {
expect(compare(bytes('{not json'), bytes('{}'), 'semantic', 'json')).toBe('unsupported');
});
});
// MARK: XML
describe('XML', () => {
it('ignores attribute order and whitespace', () => {
const left = bytes('<root><item id="1" name="uno">valor</item></root>');
const right = bytes('<root>\n <item name="uno" id="1">\n valor\n </item>\n</root>');
expect(compare(left, right, 'semantic', 'xml')).toBe('equivalent');
expect(compare(left, right, 'binary', 'xml')).toBe('different');
});
it('treats repeated siblings as a set', () => {
const left = bytes(`<Config>
<NameValuePair><name>host</name><value>localhost</value></NameValuePair>
<NameValuePair><name>port</name><value>8080</value></NameValuePair>
</Config>`);
const right = bytes(`<Config>
<NameValuePair><name>port</name><value>8080</value></NameValuePair>
<NameValuePair><name>host</name><value>localhost</value></NameValuePair>
</Config>`);
expect(compare(left, right, 'semantic', 'xml')).toBe('equivalent');
});
it('still catches a changed value inside a reordered list', () => {
const left = bytes('<C><P><k>a</k><v>1</v></P><P><k>b</k><v>2</v></P></C>');
const right = bytes('<C><P><k>b</k><v>2</v></P><P><k>a</k><v>9</v></P></C>');
expect(compare(left, right, 'semantic', 'xml')).toBe('different');
});
it('catches a dropped entry and a collapsed duplicate', () => {
expect(compare(bytes('<C><P>a</P><P>b</P><P>c</P></C>'),
bytes('<C><P>c</P><P>a</P></C>'), 'semantic', 'xml')).toBe('different');
expect(compare(bytes('<C><P>a</P><P>a</P></C>'),
bytes('<C><P>a</P></C>'), 'semantic', 'xml')).toBe('different');
});
it('keeps the order of differently named siblings significant', () => {
expect(compare(bytes('<root><a/><b/></root>'), bytes('<root><b/><a/></root>'),
'semantic', 'xml')).toBe('different');
});
it('handles a long reordered list', () => {
const entries = Array.from({ length: 400 }, (_, i) => `<P><k>key${i}</k><v>${i}</v></P>`);
const left = bytes(`<C>${entries.join('')}</C>`);
const right = bytes(`<C>${[...entries].reverse().join('')}</C>`);
expect(compare(left, right, 'semantic', 'xml')).toBe('equivalent');
});
it('renders a canonical string that ignores attribute order', () => {
const parse = (xml: string) =>
new DOMParser().parseFromString(xml, 'application/xml').documentElement;
expect(canonicalXML(parse('<a x="1" y="2"/>')))
.toBe(canonicalXML(parse('<a y="2" x="1"/>')));
});
});
// MARK: Text
describe('text', () => {
it('tolerates line endings and trailing spaces', () => {
expect(compare(bytes('uno\ndos\n'), bytes('uno \r\ndos\r\n'), 'text', 'text'))
.toBe('equivalent');
expect(compare(bytes('uno\ndos\n'), bytes('uno \r\ndos\r\n'), 'text', 'text',
{ ...defaultTextOptions, ignoreTrailingWhitespace: false, normaliseLineEndings: false }))
.toBe('different');
});
it('honours case and blank-line options', () => {
const options = { ...defaultTextOptions, ignoreCase: true, ignoreBlankLines: true };
expect(normaliseText('Hola\n\nMundo', options)).toBe(normaliseText('hola\nmundo', options));
});
});
// MARK: Detection
describe('kind detection', () => {
it('uses the extension first, then the content', () => {
expect(detectKind('a/config.json')).toBe('json');
expect(detectKind('pom.xml')).toBe('xml');
expect(detectKind('notes.txt')).toBe('text');
expect(detectKind('payload', bytes('{"a":1}'))).toBe('json');
expect(detectKind('payload', bytes('<root/>'))).toBe('xml');
expect(detectKind('payload', new Uint8Array([0, 1, 2]))).toBe('binary');
});
it('picks a sensible default mode per kind', () => {
expect(defaultMode('json')).toBe('semantic');
expect(defaultMode('xml')).toBe('semantic');
expect(defaultMode('text')).toBe('text');
expect(defaultMode('binary')).toBe('binary');
});
});
// MARK: Line diff
describe('line diff', () => {
it('pairs a changed line instead of delete plus insert', () => {
const rows = diffRows('uno\ndos\ntres', 'uno\nDOS\ntres');
expect(rows).toHaveLength(3);
expect(rows[1].kind).toBe('changed');
expect(rows[1].left).toBe('dos');
expect(rows[1].right).toBe('DOS');
});
it('reports insertions and deletions', () => {
expect(diffRows('a\nc', 'a\nb\nc').map((r) => r.kind)).toEqual(['equal', 'added', 'equal']);
expect(diffRows('a\nb\nc', 'a\nc').map((r) => r.kind)).toEqual(['equal', 'removed', 'equal']);
});
it('stays cheap on a big file with one change', () => {
const common = Array.from({ length: 20000 }, (_, i) => `line ${i}`);
const modified = [...common];
modified[10000] = 'line 10000 CHANGED';
const rows = diffRows(common.join('\n'), modified.join('\n'));
expect(rows).toHaveLength(20000);
expect(rows.filter((r) => r.kind !== 'equal')).toHaveLength(1);
});
it('highlights only the differing characters', () => {
const [left, right] = inlineRanges('let total = 3', 'let total = 4');
expect('let total = 3'.slice(left![0], left![1])).toBe('3');
expect('let total = 4'.slice(right![0], right![1])).toBe('4');
});
it('classifies whitespace, comment and content changes', () => {
expect(describeChange(' let x = 1', '\t\tlet x = 1', C_LIKE).changeKind).toBe('whitespace');
expect(describeChange('let t = 3 // vieja', 'let t = 3 // nueva', C_LIKE).changeKind).toBe('comment');
expect(describeChange('PORT=8080 # a', 'PORT=8080 # b', HASH).changeKind).toBe('comment');
expect(describeChange('let t = 3 // n', 'let t = 4 // n', C_LIKE).changeKind).toBe('content');
// Without a known syntax nothing is claimed as a comment.
expect(describeChange('v = 3 // a', 'v = 3 // b', null).changeKind).toBe('content');
});
it('infers comment syntax from the extension', () => {
expect(commentSyntaxFor('App.swift')).toBe(C_LIKE);
expect(commentSyntaxFor('deploy.sh')).toBe(HASH);
expect(commentSyntaxFor('data.bin')).toBeNull();
});
});
// MARK: Archives
describe('archives', () => {
it('reads the central directory without decompressing', async () => {
const zip = makeZip({
'META-INF/MANIFEST.MF': 'Manifest-Version: 1.0\n',
'com/example/App.class': 'A'.repeat(5000),
});
const reader = await ZipReader.open(zip);
expect(reader.entries).toHaveLength(2);
const manifest = reader.entries.find((e) => e.path === 'META-INF/MANIFEST.MF')!;
expect(manifest.uncompressedSize).toBe(22);
expect(manifest.crc32).not.toBe(0);
});
it('inflates an entry on demand', async () => {
const body = 'hola mundo '.repeat(500);
const reader = await ZipReader.open(makeZip({ 'big.txt': body }));
const entry = reader.entries.find((e) => e.path === 'big.txt')!;
expect(new TextDecoder().decode(await reader.data(entry))).toBe(body);
});
it('gives identical archives identical CRCs', async () => {
const files = { 'a.txt': 'uno', 'b/c.txt': 'dos' };
const first = await ZipReader.open(makeZip(files));
const second = await ZipReader.open(makeZip(files));
const crcs = (r: ZipReader) => r.entries.map((e) => `${e.path}:${e.crc32}`).sort();
expect(crcs(first)).toEqual(crcs(second));
});
it('detects archives by extension and by signature', async () => {
expect(hasArchiveExtension('app.jar')).toBe(true);
expect(hasArchiveExtension('notes.txt')).toBe(false);
const zip = makeZip({ 'a.txt': 'x' });
// A ZIP renamed to something unfamiliar is still a ZIP.
expect(await isArchive(zip, 'bundle.customext')).toBe(true);
expect(await isArchive(new Blob([bytes('solo texto que no es un zip')]), 'notes.unknown'))
.toBe(false);
expect(looksLikeArchive(new Uint8Array([0x50, 0x4b, 0x03, 0x04]))).toBe(true);
});
it('skips entries that cannot be archives', () => {
expect(mayBeNestedArchive('Service.class', 5000)).toBe(false);
expect(mayBeNestedArchive('beans.xml', 900)).toBe(false);
expect(mayBeNestedArchive('lib.jar', 5000)).toBe(true);
expect(mayBeNestedArchive('mystery.bundle', 5000)).toBe(true);
expect(mayBeNestedArchive('huge.bundle', 900_000_000)).toBe(false);
});
it('browses an archive as a folder', async () => {
const tree = await scanArchive(makeZip({
'META-INF/MANIFEST.MF': 'Manifest-Version: 1.0\n',
'com/example/Main.class': 'clase',
}), 'app.jar');
expect(tree.isDirectory).toBe(true);
expect(tree.isArchive).toBe(true);
const manifest = tree.children.find((c) => c.name === 'META-INF')
?.children.find((c) => c.name === 'MANIFEST.MF');
expect(manifest).toBeDefined();
expect(manifest!.crc32).toBeDefined();
});
it('expands a renamed ZIP nested inside a JAR', async () => {
const inner = makeZip({ 'deep/Service.class': 'bytecode' });
const innerBytes = new Uint8Array(await inner.arrayBuffer());
const outer = new Blob([zipSync({ 'inner.zipball': innerBytes, 'MANIFEST.MF': bytes('m') })]);
const tree = await scanArchive(outer, 'lib.jar');
const nested = tree.children.find((c) => c.name === 'inner.zipball');
expect(nested?.isDirectory).toBe(true);
expect(nested?.children.find((c) => c.name === 'deep')).toBeDefined();
});
});
// MARK: Comparing trees
describe('comparing trees', () => {
it('classifies each kind of difference', async () => {
const left = await scanFiles([
fileFor('same.txt', 'igual'),
fileFor('changed.txt', 'antes'),
fileFor('only-left.txt', 'solo A'),
fileFor('sub/nested.txt', 'profundo'),
], 'left');
const right = await scanFiles([
fileFor('same.txt', 'igual'),
fileFor('changed.txt', 'después'),
fileFor('only-right.txt', 'solo B'),
fileFor('sub/nested.txt', 'profundo'),
], 'right');
const diff = await compareTrees(left, right);
expect(row(diff, 'same.txt')?.status).toBe('identical');
expect(row(diff, 'changed.txt')?.status).toBe('different');
expect(row(diff, 'only-left.txt')?.status).toBe('onlyLeft');
expect(row(diff, 'only-right.txt')?.status).toBe('onlyRight');
expect(row(diff, 'sub')?.status).toBe('identical');
const counts = totals(diff);
expect(counts).toMatchObject({ identical: 2, different: 1, onlyLeft: 1, onlyRight: 1 });
});
it('bubbles a difference up to its folders', async () => {
const left = await scanFiles([fileFor('deep/a/b/file.txt', 'uno')], 'l');
const right = await scanFiles([fileFor('deep/a/b/file.txt', 'dos')], 'r');
const diff = await compareTrees(left, right);
expect(row(diff, 'deep')?.status).toBe('different');
});
it('does not flag semantically equal JSON and XML, and reports the mode used', async () => {
const left = await scanFiles([
fileFor('config.json', '{"a":1,"b":2}'),
fileFor('beans.xml', '<beans><bean id="a" class="X"/></beans>'),
], 'sl');
const right = await scanFiles([
fileFor('config.json', '{\n "b": 2,\n "a": 1\n}'),
fileFor('beans.xml', '<beans>\n <bean class="X" id="a"/>\n</beans>'),
], 'sr');
const diff = await compareTrees(left, right);
expect(row(diff, 'config.json')?.status).toBe('equivalent');
expect(row(diff, 'config.json')?.appliedMode).toBe('semantic');
expect(row(diff, 'beans.xml')?.status).toBe('equivalent');
// Byte for byte, the very same files do differ.
const strict = await compareTrees(left, right, { mode: 'binary' });
expect(row(strict, 'config.json')?.status).toBe('different');
expect(row(strict, 'config.json')?.appliedMode).toBe('binary');
});
it('compares two archives entry by entry', async () => {
const first = await scanArchive(makeZip({
'a.txt': 'igual', 'b.txt': 'antes', 'solo-a.txt': 'x',
}), 'one.jar');
const second = await scanArchive(makeZip({
'a.txt': 'igual', 'b.txt': 'después', 'solo-b.txt': 'y',
}), 'two.jar');
const diff = await compareTrees(first, second);
expect(row(diff, 'one.jar/a.txt')?.status).toBe('identical');
expect(row(diff, 'one.jar/b.txt')?.status).toBe('different');
expect(row(diff, 'one.jar/solo-a.txt')?.status).toBe('onlyLeft');
expect(row(diff, 'two.jar/solo-b.txt')?.status).toBe('onlyRight');
});
it('settles identical archive entries by CRC without reading them', async () => {
const files = { 'big.txt': 'dato '.repeat(10000) };
const left = await scanArchive(makeZip(files), 'l.jar');
const right = await scanArchive(makeZip(files), 'r.jar');
// A tiny eager limit means nothing may be read; the CRC still decides.
const diff = await compareTrees(left, right, { eagerSizeLimit: 1 });
expect(row(diff, 'l.jar/big.txt')?.status).toBe('identical');
});
it('leaves large differing files pending', async () => {
const left = await scanFiles([fileFor('data.bin', 'a'.repeat(2000))], 'll');
const right = await scanFiles([fileFor('data.bin', 'b'.repeat(2000))], 'rr');
const diff = await compareTrees(left, right, { eagerSizeLimit: 100 });
expect(row(diff, 'data.bin')?.status).toBe('pending');
});
});
+10
View File
@@ -0,0 +1,10 @@
/**
* Tests run on Node, not jsdom: jsdom ships a partial `Blob` whose `slice()`
* result has no `arrayBuffer()`, and reading the tail of a blob is exactly what
* the ZIP reader does. Node's own Blob/File/DecompressionStream match the
* browser, so we only borrow jsdom's XML parser.
*/
import { JSDOM } from 'jsdom';
const { window } = new JSDOM('', { contentType: 'text/html' });
globalThis.DOMParser = window.DOMParser;
+13
View File
@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"noUnusedLocals": true,
"skipLibCheck": true,
"types": ["vitest/globals"]
},
"include": ["src", "tests"]
}
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
setupFiles: ['./tests/setup.ts'],
},
});