Skip to content
Open
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
12 changes: 12 additions & 0 deletions dembrane/frontend/e2e/verify-explore-harness.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Verify and Explore harness</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/e2e/verifyExploreHarness.tsx"></script>
</body>
</html>
151 changes: 151 additions & 0 deletions dembrane/frontend/e2e/verify-explore-harness.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import { expect, type Page, test } from "@playwright/test";

// ECHO-1052: every portal state of Verify and Explore at phone width, and the portal
// editor's Verify and Explore sections at desktop width, in English and Dutch.
// E2E_SHOTS=<dir> also saves screenshots.
const shots = process.env.E2E_SHOTS;

async function answerApi(page: Page) {
// Anything the seeded queries do not cover answers empty, as a fresh project would.
await page.route("**/api/**", (route) => route.fulfill({ json: {} }));
}

const open = async (page: Page, view: string, lang: string) => {
await page.addInitScript((l) => {
localStorage.setItem("dembrane-language", l);
}, lang);
await answerApi(page);
await page.goto(`/e2e/verify-explore-harness.html?view=${view}&lang=${lang}`);
};

const noSideScroll = (page: Page) =>
page.evaluate(
() => document.documentElement.scrollWidth <= window.innerWidth,
);

for (const lang of ["en-US", "nl-NL"]) {
const nl = lang === "nl-NL";

test(`portal states at 390px, ${lang}`, async ({ page }) => {
const errors: string[] = [];
page.on("pageerror", (e) => errors.push(e.message));
await page.setViewportSize({ height: 844, width: 390 });
const shot = async (name: string) => {
if (shots)
await page.screenshot({
fullPage: true,
path: `${shots}/portal-${lang}-${name}.png`,
});
};

for (const [view, label] of [
["button-verify", nl ? "Verifiëren" : "Verify"],
["button-explore", nl ? "Verkennen" : "Explore"],
// Both on: the product name, in every language.
["button-both", "ECHO"],
] as const) {
await open(page, view, lang);
await expect(page.getByTestId("portal-audio-echo-button")).toHaveText(
label,
);
await shot(view);
expect(await noSideScroll(page)).toBe(true);
}

await open(page, "choice-both", lang);
const verify = page.getByTestId("portal-echo-verify-card");
const explore = page.getByTestId("portal-echo-explore-card");
await expect(verify).toBeVisible();
await expect(explore).toBeVisible();
for (const choice of [verify, explore]) {
const box = await choice.boundingBox();
expect(box?.height ?? 0).toBeGreaterThanOrEqual(44);
}
await shot("choice-both");
await page.keyboard.press("Tab");
await expect(verify).toBeFocused();
await shot("choice-both-focus");
expect(await noSideScroll(page)).toBe(true);

await open(page, "picker", lang);
await expect(
page.getByTestId("portal-verify-topic-agreements"),
).toBeVisible();
await page.getByTestId("portal-verify-topic-heard").click();
await expect(page.getByTestId("portal-verify-topic-heard")).toHaveAttribute(
"aria-pressed",
"true",
);
await expect(
page.getByTestId("portal-verify-selection-next-button"),
).toBeEnabled();
await page.mouse.move(0, 0);
await page.waitForTimeout(300);
await shot("picker");
expect(await noSideScroll(page)).toBe(true);

await open(page, "reminder", lang);
await page.getByTestId("portal-audio-stop-finish-button").click();
await expect(page.getByTestId("portal-audio-verify-button")).toBeVisible();
await page.waitForTimeout(300);
await shot("reminder");

expect(errors).toEqual([]);
});

test(`portal editor Verify and Explore at 1280px, ${lang}`, async ({
page,
}) => {
const errors: string[] = [];
page.on("pageerror", (e) => errors.push(e.message));
await page.setViewportSize({ height: 900, width: 1280 });
await open(page, "editor", lang);
const cross = page.getByTestId("portal-editor-cross-pollination-switch");
await expect(cross).toBeAttached();
await expect(
page.getByTestId("portal-editor-verify-topic-heard"),
).toBeAttached();
await expect(
page.getByTestId("portal-editor-ask-email-checkbox"),
).toHaveCount(0);
const explore = page.getByTestId("portal-editor-go-deeper-switch");
await explore.scrollIntoViewIfNeeded();
if (shots) {
const section = page.locator("form").first();
await section.screenshot({ path: `${shots}/editor-${lang}-full.png` });
await page
.getByTestId("portal-editor-go-deeper-switch")
.locator(
"xpath=ancestor::div[contains(@class,'mantine-Stack-root')][2]",
)
.screenshot({ path: `${shots}/editor-${lang}-explore-verify.png` });
}
expect(errors).toEqual([]);
});
}

// The recorder's footer (the ECHO, Verify or Explore button beside Stop) stays on one row
// on a 390px phone in the languages with the longest words.
for (const lang of ["nl-NL", "de-DE"]) {
test(`recorder footer on one row at 390px, ${lang}`, async ({ page }) => {
await page.setViewportSize({ height: 844, width: 390 });
for (const view of ["button-both", "button-verify", "button-explore"]) {
await open(page, view, lang);
const refine = page.getByTestId("portal-audio-echo-button");
const stop = page.getByTestId("portal-audio-stop-button");
await expect(refine).toBeVisible();
if (view === "button-both") await expect(refine).toHaveText("ECHO");
const [a, b] = [await refine.boundingBox(), await stop.boundingBox()];
expect([view, Math.abs((a?.y ?? 0) - (b?.y ?? 1)) < 2]).toEqual([
view,
true,
]);
expect(await noSideScroll(page)).toBe(true);
if (shots && lang === "de-DE")
await page.screenshot({
fullPage: true,
path: `${shots}/portal-${lang}-${view}.png`,
});
}
});
}
12 changes: 10 additions & 2 deletions dembrane/frontend/src/Router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,8 @@ import { ProjectLibraryLayout } from "./components/layout/ProjectLibraryLayout";
import { ProjectOverviewLayout } from "./components/layout/ProjectOverviewLayout";
import { WorkspaceLayout } from "./components/layout/WorkspaceLayout";
import { ParticipantConversationAudioContent } from "./components/participant/ParticipantConversationAudioContent";
import { RefineSelection } from "./components/participant/refine/RefineSelection";
import { Verify } from "./components/participant/verify/Verify";
import { VerifyArtefact } from "./components/participant/verify/VerifyArtefact";
import { VerifySelection } from "./components/participant/verify/VerifySelection";
import { ProjectAccessGuard } from "./components/project/ProjectAccessGuard";
import { ENABLE_CANVAS, ENABLE_MONITOR, ENABLE_PRESENT } from "./config";
import {
Expand All @@ -45,6 +43,16 @@ import {

// Lazy-loaded route components
// The conversation page is dashboard only; loaded lazily so the portal's first load stays small.
// The choice between Verify and Explore and the Verify picker open only after a minute of
// recording, so they stay out of the portal's first load (ECHO-1052).
const RefineSelection = createLazyNamedRoute(
() => import("./components/participant/refine/RefineSelection"),
"RefineSelection",
);
const VerifySelection = createLazyNamedRoute(
() => import("./components/participant/verify/VerifySelection"),
"VerifySelection",
);
const ProjectConversationRoute = createLazyNamedRoute(
() => import("./routes/project/conversation/ProjectConversationRoute"),
"ProjectConversationRoute",
Expand Down
1 change: 1 addition & 0 deletions dembrane/frontend/src/components/common/settingChange.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export const FIELD_LABELS: Record<string, () => string> = {
host_guide: () => t`Host guide`,
image_generation_model: () => t`Image style`,
is_conversation_allowed: () => t`Portal open for new conversations`,
is_get_reply_cross_pollination_enabled: () => t`Cross-pollination`,
is_get_reply_enabled: () => t`Replies to participants`,
is_project_notification_subscription_allowed: () =>
t`Participant updates subscription`,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { ParticipantConversationAudio } from "./ParticipantConversationAudio";
const state = vi.hoisted(() => ({
chunks: [] as unknown[],
isRecording: false,
navigate: vi.fn(),
onRecordingInterrupted: undefined as (() => void) | undefined,
onChunkAccepted: undefined as (() => void) | undefined,
onRecordingStop: undefined as
Expand All @@ -22,6 +23,9 @@ const state = vi.hoisted(() => ({
}));

vi.mock("posthog-js", () => ({ default: { capture: vi.fn() } }));
vi.mock("@/hooks/useI18nNavigate", () => ({
useI18nNavigate: () => state.navigate,
}));
vi.mock("@/lib/api", () => ({
finishConversation: vi.fn(),
pingConversation: vi.fn(async () => ({})),
Expand Down Expand Up @@ -146,6 +150,8 @@ afterEach(() => {
state.project = {};
state.recordingTime = 0;
state.stopRecording.mockReset();
state.navigate.mockReset();
localStorage.clear();
});

const page = () => (
Expand Down Expand Up @@ -292,3 +298,62 @@ it.each([
expect(screen.getByTestId("stop-modal").dataset.verify).toBe(offered);
},
);

// ECHO-1052: the button under the recorder is named after where it goes, and goes there.
const recordingAMinute = (project: Record<string, unknown>) => {
state.taking = true;
state.chunks = [{ id: "ch-1" }];
state.isRecording = true;
state.recordingTime = 61;
state.project = project;
renderPage();
return screen.queryByTestId("portal-audio-echo-button");
};

it("with only Verify on, the button says Verify and goes straight to Verify", () => {
const button = recordingAMinute({
is_get_reply_enabled: false,
is_verify_enabled: true,
});
expect(button?.textContent).toBe("Verify");
expect(button?.getAttribute("data-mode")).toBe("verify");
act(() => button?.click());
expect(state.navigate).toHaveBeenCalledWith("/p-1/conversation/c-1/verify");
});

it("with only Explore on, the button says Explore and asks for a question at once", () => {
const button = recordingAMinute({
is_get_reply_enabled: true,
is_verify_enabled: false,
});
expect(button?.textContent).toBe("Explore");
act(() => button?.click());
expect(state.navigate).toHaveBeenCalledWith("/p-1/conversation/c-1?echo=1");
expect(localStorage.getItem("cooldown_c-1_echo")).not.toBeNull();
});

it("with both on, the button is named ECHO and offers the choice", () => {
const button = recordingAMinute({
is_get_reply_enabled: true,
is_verify_enabled: true,
});
expect(button?.textContent).toBe("ECHO");
act(() => button?.click());
expect(state.navigate).toHaveBeenCalledWith("/p-1/conversation/c-1/refine");
});

it("with neither on, there is no button", () => {
expect(
recordingAMinute({ is_get_reply_enabled: false, is_verify_enabled: false }),
).toBeNull();
});

it("a locked single feature opens the choice screen, which says why", () => {
const button = recordingAMinute({
is_ai_available: false,
is_get_reply_enabled: false,
is_verify_enabled: true,
});
act(() => button?.click());
expect(state.navigate).toHaveBeenCalledWith("/p-1/conversation/c-1/refine");
});
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,9 @@ import { ParticipantRecordingWaveform } from "./ParticipantRecordingWaveform";
import { ParticipantSettingsModal } from "./ParticipantSettingsModal";
import { PermissionErrorModal } from "./PermissionErrorModal";
import { recordingClosed } from "./recordingCap";
import { RefineButtonLabel } from "./refine/RefineButtonLabel";
import { refineMode } from "./refine/refineMode";
import { useRefineActions } from "./refine/useRefineActions";
import { StopRecordingConfirmationModal } from "./StopRecordingConfirmationModal";
import { useConversationArtefacts } from "./verify/hooks";
import { WrapUpModal } from "./WrapUpModal";
Expand Down Expand Up @@ -739,13 +742,22 @@ export const ParticipantConversationAudio = () => {

const showVerify = projectQuery.data?.is_verify_enabled;
const showEcho = projectQuery.data?.is_get_reply_enabled;
const mode = refineMode(projectQuery.data);
const refine = useRefineActions({
aiUnavailable: projectQuery.data?.is_ai_available === false,
conversationId,
projectId,
});

// One feature on: straight to it. Both on, or the one on locked: the choice screen,
// which says why a locked one cannot open yet.
const handleRefineClick = () => {
if (recordingTime < REFINE_BUTTON_THRESHOLD_SECONDS) {
openRefineInfoModal();
return;
}

if (mode === "verify" && refine.goVerify()) return;
if (mode === "explore" && refine.goExplore()) return;
navigate(`/${projectId}/conversation/${conversationId}/refine`);
};

Expand Down Expand Up @@ -808,21 +820,21 @@ export const ParticipantConversationAudio = () => {

const getRefineInfoText = () => {
if (showVerify && showEcho) {
return t`Take some time to create an outcome that makes your contribution concrete or get an immediate reply from dembrane to help you deepen the conversation.`;
return t`Check what we captured together, or get a question to go deeper.`;
}
if (showVerify) {
return t`Take some time to create an outcome that makes your contribution concrete.`;
return t`Check what we captured together.`;
}
if (showEcho) {
return t`Get an immediate reply from dembrane to help you deepen the conversation.`;
return t`Get a question to go deeper.`;
}
return "";
};

const getRefineModalTitle = () => {
if (showVerify && showEcho) {
return (
<Trans id="participant.modal.echo.info.title.generic">
<Trans id="participant.modal.refine.info.title.both">
"ECHO" available soon
</Trans>
);
Expand Down Expand Up @@ -850,9 +862,8 @@ export const ParticipantConversationAudio = () => {

const getRefineInfoReason = () => {
return (
<Trans id="participant.modal.echo.info.reason">
We need a bit more context to help you use ECHO effectively. Please
continue recording so we can provide better suggestions.
<Trans id="participant.modal.refine.info.reason">
We need a bit more of your conversation first. Please keep recording.
</Trans>
);
};
Expand Down Expand Up @@ -1191,6 +1202,7 @@ export const ParticipantConversationAudio = () => {
? undefined
: "filled"
}
data-mode={mode ?? undefined}
{...testId("portal-audio-echo-button")}
>
{recordingTime < REFINE_BUTTON_THRESHOLD_SECONDS && (
Expand All @@ -1200,7 +1212,7 @@ export const ParticipantConversationAudio = () => {
/>
)}
<span className="relative z-10">
<Trans id="participant.button.echo">ECHO</Trans>
<RefineButtonLabel mode={mode} />
</span>
</Button>
)}
Expand Down
Loading
Loading