Skip to content

Commit ff5d1e9

Browse files
authored
Merge pull request #1325 from Open-Source-Legal/claude/resolve-issue-1321-zE3ic
Tighten tests per PR #1297 follow-up (issue #1321)
2 parents 7be39d7 + f23fac7 commit ff5d1e9

5 files changed

Lines changed: 104 additions & 32 deletions

File tree

CHANGELOG.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Changed
11+
12+
- **PR #1297 follow-up — tighten component tests and unit-test coverage** (Issue #1321):
13+
- `frontend/src/components/documents/__tests__/DocumentRelationshipModal.test.ts`: added a test that pins the `labelType: undefined` edge case for `filterRelationshipLabels` (previously only `null` non-relationship labels were covered), so the strict-equality guard against non-`RelationshipLabel` types is explicit.
14+
- `frontend/tests/ModernDocumentItem.ct.tsx`:
15+
- Extended the `__reactProps$` upgrade-risk comments on `clickViaReact` / `openContextMenu` to call out the build-hash suffix explicitly — the suffix rotates on every React build, so `Object.keys(el).find(k => k.startsWith("__reactProps$"))` is the only correct lookup pattern.
16+
- `openContextMenu` now falls back to a full-document scan for an element with a React `onContextMenu` prop when the `.checkbox` anchor is absent (e.g. future conditional rendering of read-only items). The primary checkbox path is unchanged.
17+
- Added `NOTE` comments on the relationship-popup `toBeAttached()` assertions explaining that they only verify DOM presence (the popup uses `visibility: hidden` + hover reveal) and when to switch to `toBeVisible()` instead.
18+
- `frontend/tests/DocumentRelationshipModal.ct.tsx`:
19+
- Replaced the DOM-order `removeButtons.nth(1)` assertion in the "removes document from target column" test with an XPath-based walk from the pill's visible title, so re-ordering the source/target layout can no longer silently test the wrong button.
20+
- Strengthened the mutation-failure negative assertion: after submitting a failing mutation, wait for the submit button to re-enable (a positive signal that the `isSubmitting` finally-block has executed) before polling `onSuccessCalled === false`. Prevents the poll from passing immediately on the initial `false` value.
21+
- `frontend/tests/utils/ReactiveVarObserver.tsx`: expanded the doc comment with a three-step recipe for extending the observer to additional reactive vars, matching the existing `viewingDocument` / `editingDocument` convention.
22+
1023
### Fixed
1124

1225
- **`fullDatacellList` no-args path was unbounded** (Issue #1256, follow-up to PR #1235, `config/graphql/extract_types.py:131-148`): `ExtractType.resolve_full_datacell_list` capped the `limit` and `offset-only` branches at `MAX_FULL_DATACELL_LIST_LIMIT` but returned the entire queryset when called with no arguments. Authenticated callers hitting `fullDatacellList` directly (no `limit`, no `offset`) could bypass the payload bound. Collapsed all three code paths so every call — no-args, offset-only, or `limit`+`offset` — returns `qs[start : start + min(limit_or_max, MAX_FULL_DATACELL_LIST_LIMIT)]`. The embed UI is unaffected (it already passes `limit: 500`); direct API callers now receive at most 500 cells and must paginate via `offset` to walk the rest. Added regression test `test_full_datacell_list_no_args_capped_at_server_max` in `opencontractserver/tests/test_extract_queries.py` which creates 501 cells and asserts the no-args response returns exactly `MAX_FULL_DATACELL_LIST_LIMIT` while `datacellCount` reports the true total.

frontend/src/components/documents/__tests__/DocumentRelationshipModal.test.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ const makeLabel = (
66
overrides: Partial<{
77
id: string;
88
text: string | null;
9-
labelType: string;
9+
labelType: string | undefined;
1010
}> = {}
1111
) => ({
1212
id: "l-1",
@@ -36,6 +36,18 @@ describe("filterRelationshipLabels", () => {
3636
expect(out.map((l) => l.id)).toEqual(["l-1", "l-3"]);
3737
});
3838

39+
it("excludes labels whose labelType is undefined (not just null)", () => {
40+
// Strict equality `=== LabelType.RelationshipLabel` excludes both null
41+
// and undefined; this exercises the undefined edge-case explicitly so a
42+
// future refactor doesn't accidentally start accepting unset labelType.
43+
const labels = [
44+
makeLabel({ id: "l-1", text: "references" }),
45+
makeLabel({ id: "l-2", text: "header", labelType: undefined }),
46+
];
47+
const out = filterRelationshipLabels(labels, "", true);
48+
expect(out.map((l) => l.id)).toEqual(["l-1"]);
49+
});
50+
3951
it("returns all relationship labels unfiltered when searchTerm is empty", () => {
4052
const labels = [
4153
makeLabel({ id: "l-1", text: "references" }),

frontend/tests/DocumentRelationshipModal.ct.tsx

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -288,10 +288,15 @@ test.describe("DocumentRelationshipModal — state transitions", () => {
288288

289289
await page.waitForSelector('text="Target Document 1"', { timeout: 10000 });
290290

291-
// The target pill's Remove button is the second one on the page
292-
// (source has one Remove, target has one Remove)
293-
const removeButtons = page.locator('button[title="Remove"]');
294-
await removeButtons.nth(1).click();
291+
// Scope the Remove click to the target pill by walking up from the
292+
// pill's visible title to the first ancestor that contains a Remove
293+
// button. This avoids the earlier DOM-order `.nth(1)` approach which
294+
// would silently click the wrong button if the source/target layout
295+
// ever changes.
296+
const targetPill = page
297+
.getByText("Target Document 1")
298+
.locator('xpath=ancestor::*[.//button[@title="Remove"]][1]');
299+
await targetPill.locator('button[title="Remove"]').click();
295300

296301
await expect(page.getByText("No target documents")).toBeVisible();
297302
});
@@ -718,15 +723,20 @@ test.describe("DocumentRelationshipModal — submit", () => {
718723
const submitBtn = page.getByRole("button", { name: /Create Relationship/ });
719724
await submitBtn.click();
720725

721-
// Modal should still be open after the failed mutation (the failure path
722-
// does not close the modal). Use a visibility assertion instead of a fixed
723-
// sleep so we only wait as long as needed for the mutation to settle.
726+
// Wait for the mutation to settle. When the mutation fails the submit
727+
// button re-enables (the component flips `isSubmitting` back off) and
728+
// the modal stays open — both are positive signals the failure path
729+
// has fully executed. Waiting for them before the negative assertion
730+
// gives `onSuccessCalled` a real window in which it *could* have been
731+
// invoked; without this stabilization the poll passes immediately on
732+
// the initial `false` value and provides no timing protection.
724733
await expect(page.getByText("Link Documents")).toBeVisible({
725734
timeout: 5000,
726735
});
736+
await expect(submitBtn).toBeEnabled({ timeout: 5000 });
727737

728738
// onSuccess should NOT be invoked when all mutations fail.
729-
await expect.poll(() => onSuccessCalled, { timeout: 3000 }).toBe(false);
739+
await expect.poll(() => onSuccessCalled, { timeout: 2000 }).toBe(false);
730740
});
731741
});
732742

frontend/tests/ModernDocumentItem.ct.tsx

Lines changed: 53 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -55,8 +55,13 @@ function renderWithProviders(
5555
* Invoke the React onClick handler directly via the __reactProps$ fiber.
5656
*
5757
* Tested against React 18.x. The `__reactProps$` key is a React internal with
58-
* no semver guarantee — if this breaks after a React upgrade, check whether
59-
* DndContext now forwards pointer events to children and remove this shim.
58+
* no semver guarantee. Two things can silently break this shim on upgrade:
59+
* 1. The property name stem (`__reactProps$`) could be renamed/removed.
60+
* 2. The suffix after `$` is a build-specific random ID that changes on
61+
* every React build (including minor/patch versions), so we must never
62+
* hard-code the full key — always find it via `Object.keys(el).find(...)`.
63+
* If this breaks after a React upgrade, check whether DndContext now forwards
64+
* pointer events to children and remove this shim.
6065
*/
6166
async function clickViaReact(
6267
page: import("@playwright/test").Page,
@@ -218,7 +223,12 @@ test.describe("ModernDocumentItem — card view rendering", () => {
218223

219224
// Number is rendered in the badge
220225
await expect(page.getByText("2").first()).toBeVisible();
221-
// Popup text is present in the DOM even before hover (visibility:hidden)
226+
// NOTE: toBeAttached() verifies DOM presence only — the relationship
227+
// popup uses `visibility: hidden` by default and reveals on hover, so
228+
// the nodes are always in the tree. If the popup ever switches to
229+
// conditional rendering (mount-on-hover), these assertions would still
230+
// pass while the feature breaks; revisit and use toBeVisible() after a
231+
// hover step in that case.
222232
await expect(
223233
page.locator('text="2 Linked Documents"').first()
224234
).toBeAttached();
@@ -326,7 +336,10 @@ test.describe("ModernDocumentItem — list view rendering", () => {
326336
mount
327337
);
328338

329-
// "1 Linked Document" (singular) should be in the popup DOM
339+
// "1 Linked Document" (singular) should be in the popup DOM.
340+
// NOTE: toBeAttached() verifies DOM presence only — see the card-view
341+
// relationship popup test above for the rationale behind not using
342+
// toBeVisible() here.
330343
await expect(
331344
page.locator('text="1 Linked Document"').first()
332345
).toBeAttached();
@@ -664,36 +677,53 @@ test.describe("ModernDocumentItem — processing state", () => {
664677
// DndContext pointer listeners don't intercept contextmenu, but to avoid
665678
// relying on coordinate-based click targeting we walk up the DOM from a known
666679
// element and invoke the React onContextMenu prop directly. Same React 18.x
667-
// `__reactProps$` caveat as clickViaReact applies.
680+
// `__reactProps$` caveat as clickViaReact applies (including the build-hash
681+
// suffix: never hard-code the full key, always discover it via Object.keys).
668682
// ---------------------------------------------------------------------------
669683

670684
async function openContextMenu(page: import("@playwright/test").Page) {
671685
await page.evaluate(() => {
672-
// Walk up from the title element to find an ancestor with onContextMenu.
686+
const dispatch = (el: HTMLElement): boolean => {
687+
const propsKey = Object.keys(el).find((k) =>
688+
k.startsWith("__reactProps$")
689+
);
690+
if (!propsKey) return false;
691+
const props = (el as any)[propsKey];
692+
if (typeof props?.onContextMenu !== "function") return false;
693+
props.onContextMenu({
694+
preventDefault: () => {},
695+
stopPropagation: () => {},
696+
clientX: 100,
697+
clientY: 100,
698+
});
699+
return true;
700+
};
701+
702+
// Prefer walking up from the checkbox — it's nested inside the item root
703+
// and guarantees we target the correct card/list ancestor. If the
704+
// checkbox is ever conditionally hidden (e.g. future read-only view),
705+
// fall back to scanning the entire document for the first element with
706+
// an onContextMenu React prop, which will be the item root because
707+
// nothing else in the tree attaches one.
673708
let el: HTMLElement | null = document.querySelector(
674709
".checkbox"
675710
) as HTMLElement | null;
676711
while (el) {
677-
const propsKey = Object.keys(el).find((k) =>
678-
k.startsWith("__reactProps$")
679-
);
680-
if (propsKey) {
681-
const props = (el as any)[propsKey];
682-
if (typeof props?.onContextMenu === "function") {
683-
props.onContextMenu({
684-
preventDefault: () => {},
685-
stopPropagation: () => {},
686-
clientX: 100,
687-
clientY: 100,
688-
});
689-
return;
690-
}
691-
}
712+
if (dispatch(el)) return;
692713
el = el.parentElement;
693714
}
715+
716+
// Fallback: scan every element in the document for a React onContextMenu
717+
// handler. Order is document order, so the outermost handler wins — which
718+
// is fine because only the item root attaches one.
719+
const all = document.querySelectorAll<HTMLElement>("*");
720+
for (const candidate of Array.from(all)) {
721+
if (dispatch(candidate)) return;
722+
}
723+
694724
throw new Error(
695-
"openContextMenu: no onContextMenu handler found walking up from .checkbox — " +
696-
"is the component rendered with the checkbox present?"
725+
"openContextMenu: no element in the document carries a React " +
726+
"onContextMenu prop — is the ModernDocumentItem rendered?"
697727
);
698728
});
699729
}

frontend/tests/utils/ReactiveVarObserver.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,13 @@ import { viewingDocument, editingDocument } from "../../src/graphql/cache";
77
* and the component under test (browser) see separate instances. Mounting
88
* this observer inside the tree exposes the browser-side values via data
99
* attributes, which the test can query without crossing the process boundary.
10+
*
11+
* Currently observes `viewingDocument` and `editingDocument`. To expose an
12+
* additional reactive var, follow the same pattern:
13+
* 1. Import the var from `../../src/graphql/cache`.
14+
* 2. Subscribe with `useReactiveVar(...)` inside the component.
15+
* 3. Render its value as a `data-<name>-id` attribute on the same element.
16+
* Tests then read it with `page.getByTestId("rv-observer").getAttribute(...)`.
1017
*/
1118
export const ReactiveVarObserver: React.FC = () => {
1219
const viewing = useReactiveVar(viewingDocument);

0 commit comments

Comments
 (0)