deer-flow/frontend/tests/e2e/settings-memory-import.spec.ts
tomyan007 45411cee69
feat(memory): add cognitiveStyle for collaboration preferences (#3182)
* feat(memory): add cognitiveStyle for collaboration preferences

Extend cross-session memory with user.cognitiveStyle and cognitive facts,
backward-compatible normalization, prompt injection as Thinking Style, and
settings UI. Add design notes and unit tests.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(memory): normalize legacy memory imports on frontend

Add normalizeMemoryPayload aligned with backend normalize_memory_data,
use it for Settings import and API reads, and add unit/regression tests.
Document schema-evolution steps and sync FACT_EXTRACTION_PROMPT cognitive category.

* fix(memory): harden legacy memory normalization

* fix(memory): keep import envelope validation strict

* fix(memory): keep unknown fields through frontend import normalization

normalizeMemoryPayload rebuilt the memory document from a section whitelist,
so every key it did not name was dropped. Because readMemoryResponse routes
all memory API responses through it, the loss showed up on read, export, and
import alike: revision vanished, and any future backend field would silently
break lossless export/import.

The Gateway models are extra="allow" and deermem normalizes additively, which
left the frontend as the only lossy hop in the chain. Spread before overriding
at each nesting level so normalization only validates and defaults
contract-owned fields, leaving the envelope check as strict as before.

Covered by a unit test on the strict import path, two backend tests for
extension preservation and caller immutability, and an E2E round-trip that
asserts on the captured import request body.

* docs(memory): state the normalization invariant that actually holds

The docstring cited `display` as a backend-driven field alongside
`revision`, but #4546 was closed unmerged and no such field exists in the
contract. It also promised lossless export/import, which the Gateway's
`extra="ignore"` models preclude for any undeclared field.

Replace both with the invariant the code is really defending: the frontend
must not be narrower than the Gateway contract, using the declared
top-level `revision` as the concrete example.

* fix(memory): preserve legacy migration and reject lossy imports

* fix(memory): align legacy fact normalization defaults

* test(memory): use authenticated app for import regression

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-09-23 18:29:12 +08:00

195 lines
5.5 KiB
TypeScript

import { expect, test, type Page } from "@playwright/test";
import { mockLangGraphAPI } from "./utils/mock-api";
const EMPTY_MEMORY = {
version: "1.0",
lastUpdated: "",
user: {
workContext: { summary: "", updatedAt: "" },
personalContext: { summary: "", updatedAt: "" },
topOfMind: { summary: "", updatedAt: "" },
cognitiveStyle: { summary: "", updatedAt: "" },
},
history: {
recentMonths: { summary: "", updatedAt: "" },
earlierContext: { summary: "", updatedAt: "" },
longTermBackground: { summary: "", updatedAt: "" },
},
facts: [],
};
const LEGACY_MEMORY_WITHOUT_COGNITIVE_STYLE = {
version: "1.0",
lastUpdated: "2026-01-01T00:00:00Z",
user: {
workContext: { summary: "Works on DeerFlow", updatedAt: "" },
personalContext: { summary: "", updatedAt: "" },
topOfMind: { summary: "Memory import compatibility", updatedAt: "" },
},
history: {
recentMonths: { summary: "", updatedAt: "" },
earlierContext: { summary: "", updatedAt: "" },
longTermBackground: { summary: "", updatedAt: "" },
},
facts: [
{
content: "User prefers conclusions first.",
category: "cognitive",
},
],
};
async function openMemorySettings(page: Page) {
mockLangGraphAPI(page);
await page.route(/\/api\/memory$/, async (route) => {
if (route.request().method() === "GET") {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(EMPTY_MEMORY),
});
return;
}
await route.fallback();
});
await page.goto("/workspace/chats/new");
const sidebar = page.locator("[data-sidebar='sidebar']");
await sidebar.getByRole("button", { name: /Settings and more/ }).click();
await page.getByRole("menuitem", { name: "Settings" }).click();
const settingsDialog = page.getByRole("dialog", { name: "Settings" });
await expect(settingsDialog).toBeVisible();
await settingsDialog.getByRole("button", { name: "Memory" }).click();
await expect(
settingsDialog.getByRole("button", { name: "Import memory" }),
).toBeVisible();
return settingsDialog;
}
async function selectMemoryFile(
settingsDialog: ReturnType<Page["getByRole"]>,
fileName: string,
payload: unknown,
) {
await settingsDialog.locator('input[type="file"]').setInputFiles({
name: fileName,
mimeType: "application/json",
buffer: Buffer.from(JSON.stringify(payload)),
});
}
const invalidImports = [
{
name: "facts-only JSON",
fileName: "facts-only.json",
payload: { facts: [] },
},
{
name: "missing metadata",
fileName: "missing-metadata.json",
payload: { user: {}, history: {}, facts: [] },
},
{
name: "non-object user/history",
fileName: "invalid-sections.json",
payload: {
version: "1.0",
lastUpdated: "2026-07-17T00:00:00Z",
user: "not-an-object",
history: 123,
facts: [],
},
},
];
test.describe("Memory settings import validation", () => {
for (const invalidImport of invalidImports) {
test(`does not enable confirmation for ${invalidImport.name}`, async ({
page,
}) => {
const settingsDialog = await openMemorySettings(page);
await selectMemoryFile(
settingsDialog,
invalidImport.fileName,
invalidImport.payload,
);
await expect(
page.getByText(
"Failed to read the selected memory file. Please choose a valid JSON export.",
),
).toBeVisible();
await expect(
page.getByRole("dialog", { name: "Import memory?" }),
).toHaveCount(0);
});
}
test("keeps confirmation available for a legacy export missing cognitiveStyle", async ({
page,
}) => {
const settingsDialog = await openMemorySettings(page);
await selectMemoryFile(
settingsDialog,
"legacy-without-cognitive-style.json",
LEGACY_MEMORY_WITHOUT_COGNITIVE_STYLE,
);
const confirmDialog = page.getByRole("dialog", { name: "Import memory?" });
await expect(confirmDialog).toBeVisible();
await expect(confirmDialog).toContainText(
"legacy-without-cognitive-style.json",
);
await expect(
confirmDialog.getByRole("button", { name: "Import" }),
).toBeEnabled();
});
test("round-trips unknown fields through the import request", async ({
page,
}) => {
const memoryWithExtensions = {
...LEGACY_MEMORY_WITHOUT_COGNITIVE_STYLE,
revision: 4,
display: { title: "Memory export" },
data: { future: true },
};
const importedPayloads: unknown[] = [];
await page.route(/\/api\/memory\/import$/, async (route) => {
if (route.request().method() === "POST") {
importedPayloads.push(route.request().postDataJSON());
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(memoryWithExtensions),
});
return;
}
await route.fallback();
});
const settingsDialog = await openMemorySettings(page);
await selectMemoryFile(
settingsDialog,
"with-extensions.json",
memoryWithExtensions,
);
const confirmDialog = page.getByRole("dialog", { name: "Import memory?" });
await expect(confirmDialog).toBeVisible();
await confirmDialog.getByRole("button", { name: "Import" }).click();
await expect.poll(() => importedPayloads.length).toBe(1);
expect(importedPayloads[0]).toMatchObject({
revision: 4,
display: { title: "Memory export" },
data: { future: true },
user: { cognitiveStyle: { summary: "", updatedAt: "" } },
});
});
});