Skip to content
Merged
Show file tree
Hide file tree
Changes from 12 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
90 changes: 49 additions & 41 deletions app/e2e/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,18 @@
import { SidebarPage } from "./pages/sidebar";

// Load test container env vars (quiet suppresses dotenvx tip banners).
dotenv.config({ path: path.resolve(__dirname, "..", ".env.test"), quiet: true });
dotenv.config({
path: path.resolve(__dirname, "..", ".env.test"),
quiet: true,
});

/** Seed user credentials (from docker/postgres/init.sql). */
export const ALICE = { email: "alice@example.com", password: "password123" };
export const BOB = { email: "bob@example.com", password: "password123" };

/** Dynamic test container URLs. */
export const TEST_NEO4J_BOLT_URL = process.env.TEST_NEO4J_BOLT_URL ?? "bolt://localhost:7687";
export const TEST_NEO4J_BOLT_URL =
process.env.TEST_NEO4J_BOLT_URL ?? "bolt://localhost:7687";
export const TEST_PG_PORT = process.env.TEST_PG_PORT ?? "5432";

type Fixtures = {
Expand Down Expand Up @@ -74,15 +78,21 @@
// destroy and recreate the CM6 editor mid-flow.
await expect(async () => {
// Wait for CM6 to mount (re-checked each iteration in case of remount)
await cmContainer.locator(".cm-editor").waitFor({ state: "visible", timeout: 5_000 });
await cmContainer
.locator(".cm-editor")
.waitFor({ state: "visible", timeout: 5_000 });

// Wait for the React wrapper to signal writable
await expect(cmContainer).toHaveAttribute("data-readonly", "false", { timeout: 5_000 });
await expect(cmContainer).toHaveAttribute("data-readonly", "false", {
timeout: 5_000,
});

// Wait for initEditor to complete (view + compartments fully initialized).
// This prevents the race where data-readonly is "false" but the CM6 view
// hasn't been created yet because async imports are still in progress.
await expect(cmContainer).toHaveAttribute("data-editor-ready", "true", { timeout: 5_000 });
await expect(cmContainer).toHaveAttribute("data-editor-ready", "true", {
timeout: 5_000,
});

// Pre-dispatch stability: poll until the editor has been continuously
// ready for 3 consecutive checks (600ms stable window). Connection and
Expand All @@ -106,58 +116,56 @@
}

// Strategy 1: Use CM6's internal dispatch API (most reliable).
// CM6 decorates managed DOM nodes with a `cmTile` property (Tile instance).
// We mirror EditorView.findFromDOM(): try .cm-content first, then .cm-editor.
const dispatched = await cmContainer.evaluate((el: HTMLElement, text: string) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function findView(node: Element | null): any {
if (!node) return null;
// The QueryEditor component exposes `__cmView` on the container DOM element
// when initialization completes. This is more reliable than the internal
// `cmTile` property which may be mangled or inaccessible in production builds.
const dispatched = await cmContainer.evaluate(
(el: HTMLElement, text: string) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const tile = (node as any).cmTile;
return tile?.root?.view ?? tile?.view ?? null;
}
const cmContent = el.querySelector(".cm-content");
if (!cmContent) return "no-editor";
const view = findView(cmContent) ?? findView(el.querySelector(".cm-editor"));
if (!view) return "no-view";
if (view.state.readOnly) return "readonly";

// Replace entire document content
view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: text },
});
return view.state.doc.toString().includes(text.substring(0, 20))
? "ok"
: "dispatch-failed";
}, query);
const view = (el as any).__cmView;
if (!el.querySelector(".cm-content")) return "no-editor";
if (!view) return "no-view";
if (view.state.readOnly) return "readonly";

// Replace entire document content
view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: text },
});
return view.state.doc.toString().includes(text.substring(0, 20))
? "ok"
: "dispatch-failed";
},
query,
);

if (dispatched === "ok") {
// Post-dispatch stability: verify text survives any late re-renders.
// The pre-dispatch check handles most cases; this is a safety net.
// eslint-disable-next-line playwright/no-wait-for-timeout
await page.waitForTimeout(300);
const stillPresent = await cmContainer.evaluate((el: HTMLElement, text: string) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function findView(node: Element | null): any {
if (!node) return null;
const stillPresent = await cmContainer.evaluate(
(el: HTMLElement, text: string) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const tile = (node as any).cmTile;
return tile?.root?.view ?? tile?.view ?? null;
}
const view = findView(el.querySelector(".cm-content")) ?? findView(el.querySelector(".cm-editor"));
if (!view) return false;
return view.state.doc.toString().includes(text.substring(0, 20));
}, query);
const view = (el as any).__cmView;
if (!view) return false;
return view.state.doc.toString().includes(text.substring(0, 20));
},
query,
);
if (!stillPresent) {
throw new Error("Text overwritten after dispatch (editor re-mounted) — retrying");
throw new Error(
"Text overwritten after dispatch (editor re-mounted) — retrying",
);
}
return;
}

// Strategy 2: Keyboard fallback (for environments where cmView is not accessible
// or when the view is temporarily readonly during initialization)
if (dispatched === "no-view" || dispatched === "readonly") {
await expect(cm).toHaveAttribute("contenteditable", "true", { timeout: 2_000 });
await expect(cm).toHaveAttribute("contenteditable", "true", {
timeout: 2_000,
});
await cm.click();
await page.keyboard.press("ControlOrMeta+a");
await page.keyboard.press("Backspace");
Expand All @@ -171,7 +179,7 @@

// Retry-worthy states: no-editor, dispatch-failed
throw new Error(`CM6 dispatch returned "${dispatched}" — retrying`);
}).toPass({ timeout: 20_000 });

Check failure on line 182 in app/e2e/fixtures.ts

View workflow job for this annotation

GitHub Actions / E2E Tests (Playwright)

[chromium] › e2e/widget-lab.spec.ts:125:9 › Widget Lab › Save / browse / delete template flow › can delete a template from Widget Lab

7) [chromium] › e2e/widget-lab.spec.ts:125:9 › Widget Lab › Save / browse / delete template flow › can delete a template from Widget Lab Error: Keyboard fallback: text not inserted Call Log: - Timeout 20000ms exceeded while waiting on the predicate at fixtures.ts:182 180 | // Retry-worthy states: no-editor, dispatch-failed 181 | throw new Error(`CM6 dispatch returned "${dispatched}" — retrying`); > 182 | }).toPass({ timeout: 20_000 }); | ^ 183 | } 184 | 185 | /** at typeInEditor (/home/runner/work/neoboard/neoboard/app/e2e/fixtures.ts:182:6) at addBarWidgetToDashboard (/home/runner/work/neoboard/neoboard/app/e2e/widget-lab.spec.ts:17:21) at /home/runner/work/neoboard/neoboard/app/e2e/widget-lab.spec.ts:75:7

Check failure on line 182 in app/e2e/fixtures.ts

View workflow job for this annotation

GitHub Actions / E2E Tests (Playwright)

[chromium] › e2e/parameters.spec.ts:1516:7 › Action rules — multi-rule editor › should add multiple action rules and configure navigate-to-page

6) [chromium] › e2e/parameters.spec.ts:1516:7 › Action rules — multi-rule editor › should add multiple action rules and configure navigate-to-page Error: Keyboard fallback: text not inserted Call Log: - Timeout 20000ms exceeded while waiting on the predicate at fixtures.ts:182 180 | // Retry-worthy states: no-editor, dispatch-failed 181 | throw new Error(`CM6 dispatch returned "${dispatched}" — retrying`); > 182 | }).toPass({ timeout: 20_000 }); | ^ 183 | } 184 | 185 | /** at typeInEditor (/home/runner/work/neoboard/neoboard/app/e2e/fixtures.ts:182:6) at /home/runner/work/neoboard/neoboard/app/e2e/parameters.spec.ts:1550:25

Check failure on line 182 in app/e2e/fixtures.ts

View workflow job for this annotation

GitHub Actions / E2E Tests (Playwright)

[chromium] › e2e/form-widget.spec.ts:348:7 › Form widget › form widget refreshes another widget on submit when configured

5) [chromium] › e2e/form-widget.spec.ts:348:7 › Form widget › form widget refreshes another widget on submit when configured Error: Keyboard fallback: text not inserted Call Log: - Timeout 20000ms exceeded while waiting on the predicate at fixtures.ts:182 180 | // Retry-worthy states: no-editor, dispatch-failed 181 | throw new Error(`CM6 dispatch returned "${dispatched}" — retrying`); > 182 | }).toPass({ timeout: 20_000 }); | ^ 183 | } 184 | 185 | /** at typeInEditor (/home/runner/work/neoboard/neoboard/app/e2e/fixtures.ts:182:6) at /home/runner/work/neoboard/neoboard/app/e2e/form-widget.spec.ts:360:23

Check failure on line 182 in app/e2e/fixtures.ts

View workflow job for this annotation

GitHub Actions / E2E Tests (Playwright)

[chromium] › e2e/form-widget.spec.ts:266:7 › Form widget › required field blocks submit and shows error when empty

4) [chromium] › e2e/form-widget.spec.ts:266:7 › Form widget › required field blocks submit and shows error when empty Error: Keyboard fallback: text not inserted Call Log: - Timeout 20000ms exceeded while waiting on the predicate at fixtures.ts:182 180 | // Retry-worthy states: no-editor, dispatch-failed 181 | throw new Error(`CM6 dispatch returned "${dispatched}" — retrying`); > 182 | }).toPass({ timeout: 20_000 }); | ^ 183 | } 184 | 185 | /** at typeInEditor (/home/runner/work/neoboard/neoboard/app/e2e/fixtures.ts:182:6) at /home/runner/work/neoboard/neoboard/app/e2e/form-widget.spec.ts:277:23

Check failure on line 182 in app/e2e/fixtures.ts

View workflow job for this annotation

GitHub Actions / E2E Tests (Playwright)

[chromium] › e2e/charts.spec.ts:1086:7 › Column mapping overlay › changing axis mapping updates chart

3) [chromium] › e2e/charts.spec.ts:1086:7 › Column mapping overlay › changing axis mapping updates chart Error: Keyboard fallback: text not inserted Call Log: - Timeout 20000ms exceeded while waiting on the predicate at fixtures.ts:182 180 | // Retry-worthy states: no-editor, dispatch-failed 181 | throw new Error(`CM6 dispatch returned "${dispatched}" — retrying`); > 182 | }).toPass({ timeout: 20_000 }); | ^ 183 | } 184 | 185 | /** at typeInEditor (/home/runner/work/neoboard/neoboard/app/e2e/fixtures.ts:182:6) at /home/runner/work/neoboard/neoboard/app/e2e/charts.spec.ts:1099:23

Check failure on line 182 in app/e2e/fixtures.ts

View workflow job for this annotation

GitHub Actions / E2E Tests (Playwright)

[chromium] › e2e/charts.spec.ts:1041:7 › Column mapping overlay › overlay visible on bar chart in edit mode

2) [chromium] › e2e/charts.spec.ts:1041:7 › Column mapping overlay › overlay visible on bar chart in edit mode Error: Keyboard fallback: text not inserted Call Log: - Timeout 20000ms exceeded while waiting on the predicate at fixtures.ts:182 180 | // Retry-worthy states: no-editor, dispatch-failed 181 | throw new Error(`CM6 dispatch returned "${dispatched}" — retrying`); > 182 | }).toPass({ timeout: 20_000 }); | ^ 183 | } 184 | 185 | /** at typeInEditor (/home/runner/work/neoboard/neoboard/app/e2e/fixtures.ts:182:6) at /home/runner/work/neoboard/neoboard/app/e2e/charts.spec.ts:1055:23
}

/**
Expand Down
35 changes: 19 additions & 16 deletions app/src/app/(dashboard)/[id]/edit/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -485,7 +485,9 @@ export default function DashboardEditorPage({
initialTemplate={
pendingTemplateId ? templateMap[pendingTemplateId] : undefined
}
initialPreviewData={editorMode === "edit" ? cachedPreviewData : undefined}
initialPreviewData={
editorMode === "edit" ? cachedPreviewData : undefined
}
/>

{templateWidget &&
Expand Down Expand Up @@ -545,23 +547,24 @@ export default function DashboardEditorPage({
<DashboardContainer
page={page}
editable
onRemoveWidget={removeWidget}
onEditWidget={openEditWidget}
onDuplicateWidget={duplicateWidget}
onLayoutChange={isActive ? updateGridLayout : undefined}
onWidgetSettingsChange={(widgetId, settings) => {
const target = page.widgets.find(
(w) => w.id === widgetId,
);
if (target) {
updateWidget(widgetId, { ...target, settings });
}
actions={{
onRemoveWidget: removeWidget,
onEditWidget: openEditWidget,
onDuplicateWidget: duplicateWidget,
onLayoutChange: isActive ? updateGridLayout : undefined,
onWidgetSettingsChange: (widgetId, settings) => {
const target = page.widgets.find(
(w) => w.id === widgetId,
);
if (target)
updateWidget(widgetId, { ...target, settings });
},
onNavigateToPage: handleNavigateToPage,
onSaveAsTemplate: setTemplateWidget,
onSyncWidget: handleSyncWidget,
onDetachWidget: handleDetachWidget,
}}
onNavigateToPage={handleNavigateToPage}
onSaveAsTemplate={setTemplateWidget}
templateMap={templateMap}
onSyncWidget={handleSyncWidget}
onDetachWidget={handleDetachWidget}
showParameterBar={showParameterBar}
/>
</div>
Expand Down
Loading
Loading