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
17 changes: 17 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,23 @@ jobs:
- name: Run file-browser DOM test (isolated)
run: DOM_TESTS=1 bun test packages/ui/hooks/useFileBrowser.test.tsx

# These two render against the REAL @pierre/diffs, because the defects
# they cover (a stale render cache served across a cacheKey collision;
# an empty stub card) only exist inside its renderer. A sibling in the
# list below, AllFilesCodeView.lifecycle.test.tsx, calls
# `mock.module('@pierre/diffs', ...)` with a hunk-less getSingularPatch
# and `processFile: () => null` — and bun's module mocks are process
# global with no restore, so in one shared process the real renderer is
# not guaranteed to still be there by the time these load. That leak
# reproduced on the Linux runner and not on macOS. Their own process
# removes the coupling outright, same reasoning as the isolated step
# above.
- name: Run diff-renderer DOM tests (isolated, real @pierre/diffs)
run: >-
DOM_TESTS=1 bun test
packages/review-editor/components/DiffViewer.fullContentSwap.test.tsx
packages/review-editor/components/DiffViewer.oversizedStub.test.tsx

# Seam contracts + the remaining DOM-gated tests. Scoped to the DOM files
# (not the whole ui suite) to keep this process light.
- name: Run UI seam-contract + DOM tests
Expand Down
11 changes: 9 additions & 2 deletions packages/editor/App.archiveReadOnly.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,14 @@ describe.if(hasDom)("App document permissions", () => {
const codeBlock = document.querySelector<HTMLElement>('pre')?.closest<HTMLElement>('[data-block-id]');
const code = codeBlock?.querySelector('code');
if (!codeBlock || !code) throw new Error("Archived fenced code block did not render");
const renderedCode = code.innerHTML;
// Text, NOT innerHTML: `applyHighlight` writes plain text first and swaps in
// Shiki markup once the grammar attaches, so the fence's MARKUP legitimately
// changes on its own schedule and an innerHTML comparison races that swap.
// What this test is actually asserting is that the click/hover opened no
// mutation entry point, which is exactly what the text plus the absence of an
// annotation `<mark>` says (a code-block annotation is one whole-fence
// `<mark data-bind-id>`, per codeBlockMark.ts).
const renderedCodeText = code.textContent;
await act(async () => {
codeBlock.dispatchEvent(new MouseEvent("mouseover", { bubbles: true }));
codeBlock.dispatchEvent(new MouseEvent("click", { bubbles: true }));
Expand All @@ -189,7 +196,7 @@ describe.if(hasDom)("App document permissions", () => {
expect(document.querySelector('textarea')).toBeNull();
expect(document.querySelector('[data-quick-label-picker]')).toBeNull();
expect(code.querySelector('mark')).toBeNull();
expect(code.innerHTML).toBe(renderedCode);
expect(code.textContent).toBe(renderedCodeText);

const optionsButton = document.querySelector<HTMLButtonElement>('button[title="Options"]');
if (!optionsButton) throw new Error("Options menu trigger did not render");
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { afterEach, describe, expect, mock, test } from 'bun:test';
import { afterAll, afterEach, describe, expect, mock, test } from 'bun:test';
import React, { act, useCallback, useEffect, useImperativeHandle, useRef } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import type { DiffFile } from '../types';
Expand All @@ -7,6 +7,16 @@ let codeViewMounts = 0;
let codeViewUnmounts = 0;
let scrollTargets: Array<Record<string, unknown>> = [];

// Captured BEFORE the mocks below replace the specifiers, so this file can put
// the real modules back when it is done. `mock.module` is process global and
// bun does not unwind it at file boundaries: without this, every later file in
// the same run sees this file's stubs — a `getSingularPatch` with no hunks and
// a `processFile` that returns null. That leaked into
// DiffViewer.fullContentSwap.test.tsx on the Linux runner (and not on macOS),
// where it presented as a diff that silently never rendered.
const realPierreDiffs = await import('@pierre/diffs');
const realPierreDiffsReact = await import('@pierre/diffs/react');

mock.module('../workerPool', () => ({
useIsWorkerPoolReadyOrDisabled: () => true,
useWorkerPoolThemeSync: () => {},
Expand Down Expand Up @@ -138,6 +148,14 @@ afterEach(async () => {
scrollTargets = [];
});

// Hand the real @pierre/diffs back to the process. Only the two library
// specifiers are restored: the sibling-module mocks above name paths relative
// to THIS file, so they cannot be reached by another file's imports.
afterAll(() => {
mock.module('@pierre/diffs', () => realPierreDiffs);
mock.module('@pierre/diffs/react', () => realPierreDiffsReact);
});

describe('AllFilesCodeView guide mount state', () => {
test.skipIf(!hasDom)('does not remount when the live shell collapse value changes', async () => {
host = document.createElement('div');
Expand Down
21 changes: 9 additions & 12 deletions packages/review-editor/components/AllFilesCodeView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ import { buildFileTree, getVisualFileOrder } from '../utils/buildFileTree';
import { buildCodeNavRequest } from '../utils/buildCodeNavRequest';
import { getDiffSelection, getLineNumberFromNode, getSideFromNode } from '../utils/diffSelection';
import { isContentConsistentWithPatch } from '../utils/patchConsistency';
import { hashString } from '../utils/hashString';
import { isOversizedReviewStubPatch } from '@plannotator/shared/diff-paths';
import { OversizedFileNotice } from './OversizedFileNotice';
import { ToolbarHost, type ToolbarHostHandle } from './ToolbarHost';
import { FileHeader } from './FileHeader';
import { EditSessionHud } from './EditSessionHud';
Expand Down Expand Up @@ -307,18 +310,6 @@ interface ItemIdentity {
itemIdToFile: Map<string, DiffFile>;
}

// Cheap content hash (djb2 xor variant) for diff-change detection. Replaces
// patch-LENGTH proxies: a same-length different-content patch set must still
// remount CodeView (fileSetKey) and must not collide in highlight caches
// (cacheKey). Not cryptographic — collision odds for this purpose are fine.
function hashString(value: string): string {
let hash = 5381;
for (let i = 0; i < value.length; i++) {
hash = ((hash * 33) ^ value.charCodeAt(i)) >>> 0;
}
return hash.toString(36);
}

// The first rendered line of a file's diff, used to anchor file-scoped comments.
// Pierre suppresses the header-prefix slot whenever a custom header is present
// (renderDiffChildren makes them mutually exclusive), so file comments can't
Expand Down Expand Up @@ -2184,6 +2175,12 @@ export const AllFilesCodeView: React.FC<AllFilesCodeViewProps> = ({
}
onCollapseToggle={() => toggleItemCollapsed(item.id)}
/>
{/* Files over the review size cap arrive as a contents-free stub, so
Pierre renders nothing below the header. Explain why rather than
leaving a bare header that reads as a broken diff. */}
{!collapsed && isOversizedReviewStubPatch(file.patch) && (
<OversizedFileNotice onHeightChange={() => refreshItem(item.id)} />
)}
{/* EXPERIMENTAL edit-session HUD: session controls + state in a slim
strip below the header, above the file content. Appears/disappears
with session start/end, which both go through a version-bumped
Expand Down
Loading