From c43a272b74fee6d3d70e2008c045776dd6313884 Mon Sep 17 00:00:00 2001 From: Mathieu Audebert Date: Tue, 13 Oct 2026 17:16:12 +0200 Subject: [PATCH 1/2] =?UTF-8?q?feat(console):=20the=20screen=20of=20an=20i?= =?UTF-8?q?nvitation:=20who=20invites,=20the=20role,=20=C2=AB=20Join=20?= =?UTF-8?q?=C2=BB,=20then=20=C2=AB=20You=20are=20in=20=C2=BB=20(FRU-241)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The commits of the branch as one, on the branch of #267 after its rebase on main. Co-Authored-By: Claude Opus 5.5 --- apps/console/app/messages.fr.ts | 24 +- apps/console/app/routes/invitations.tsx | 427 +++++++++++++++++------- docs/decisions/console.md | 14 + e2e/console-invitation.spec.ts | 334 +++++++++++++++++- 4 files changed, 678 insertions(+), 121 deletions(-) diff --git a/apps/console/app/messages.fr.ts b/apps/console/app/messages.fr.ts index 21ee18b7..c760d396 100644 --- a/apps/console/app/messages.fr.ts +++ b/apps/console/app/messages.fr.ts @@ -4,7 +4,6 @@ * A name of a product is a key too when a screen shows it through `t`, and it reads the same. */ export const FRENCH: Readonly> = { - Fruitback: 'Fruitback', Linear: 'Linear', 'Fruitback cannot reach its server. Check your connection.': @@ -192,6 +191,7 @@ export const FRENCH: Readonly> = { '{name} leaves the workspace, and their extension stops reading its sites. Their notes stay.': '{name} quitte l’espace de travail, et son extension cesse de lire ses sites. Ses notes restent.', 'Invitation sent': 'Invitation envoyée', + 'Until {date}': 'Jusqu’au {date}', 'Invitation kept': 'Invitation gardée', 'Sent {date}': 'Envoyée le {date}', Joined: 'Arrivée', @@ -230,8 +230,6 @@ export const FRENCH: Readonly> = { 'No invitation waits for {email}. An invitation sent to another address shows to that address only.': 'Aucune invitation n’attend {email}. Une invitation envoyée à une autre adresse ne s’affiche qu’à cette adresse.', 'Go to the console': 'Aller à la console', - 'For {email}.': 'Pour {email}.', - 'Until {date}': 'Jusqu’au {date}', Decline: 'Refuser', 'Joining…': 'Arrivée…', 'Join {workspace}': 'Rejoindre {workspace}', @@ -497,4 +495,24 @@ export const FRENCH: Readonly> = { Change: 'Modifier', '{site} is still in your sites, with its notes. Remove it in Sites if you no longer review it.': '{site} est toujours dans vos sites, avec ses notes. Retirez-le dans Sites si vous ne le relisez plus.', + // The screen of an invitation (FRU-241). + '{workspace} invited you': '{workspace} vous a invité', + 'Join {workspace} on Fruitback': 'Rejoignez {workspace} sur Fruitback', + 'You join as an admin: you manage the sites, the connectors and the members of {workspace}.': + 'Vous rejoignez en tant qu’admin : vous gérez les sites, les connecteurs et les membres de {workspace}.', + 'You join as a member: you see and leave notes on every site of {workspace}.': + 'Vous rejoignez en tant que membre : vous voyez et laissez des notes sur tous les sites de {workspace}.', + 'Signed in as': 'Connecté en tant que', + 'Not {email}?': 'Vous n’êtes pas {email} ?', + 'One more invitation': 'Une autre invitation', + '{count} more invitations': '{count} autres invitations', + 'You are in': 'Vous y êtes', + 'One more thing: the extension shows the notes on the sites of {workspace} and lets you leave yours.': + 'Une dernière chose : l’extension affiche les notes sur les sites de {workspace} et vous permet de laisser les vôtres.', + 'One more thing: the extension shows the notes on {site} and lets you leave yours.': + 'Une dernière chose : l’extension affiche les notes sur {site} et vous permet de laisser les vôtres.', + 'Add to Chrome': 'Ajouter à Chrome', + 'Open {site}': 'Ouvrir {site}', + 'Using Firefox?': 'Vous utilisez Firefox ?', + 'Get the Firefox add-on': 'Obtenir le module pour Firefox', }; diff --git a/apps/console/app/routes/invitations.tsx b/apps/console/app/routes/invitations.tsx index 63af3bab..c59dd94c 100644 --- a/apps/console/app/routes/invitations.tsx +++ b/apps/console/app/routes/invitations.tsx @@ -1,43 +1,87 @@ -import { useCallback, useEffect, useState } from 'react'; -import { Link, useNavigate } from 'react-router'; -import { type Invitation, type Me, ORGANIZATION, call, callUntilAnswered, myInvitations } from '../api'; -import { problemOf } from '../problems'; -import { Button, Card, Chip, Mark, Problem } from '../ui'; +import { type ReactNode, useCallback, useEffect, useState } from 'react'; +import { useNavigate } from 'react-router'; +import { + type Invitation, + type Me, + ORGANIZATION, + type Site, + call, + callUntilAnswered, + myInvitations, + signOut, +} from '../api'; import { msg, t } from '../i18n'; +import { problemOf } from '../problems'; +import { withStrong } from '../sign-in-form'; +import { Button, EntryPage, Initial, KeyValue, Lead, LinkButton, Problem, SuccessMark, Title } from '../ui'; import { useLocale } from '../use-locale'; /** - * The invitations that wait for the person who is signed in (FRU-104): where the link of an - * invitation leads. + * The invitation that waits for the person who is signed in (FRU-104), as screen 03 of the mockup of + * 2026-10-13 draws it (FRU-241): who invites, the role, « Join », then « You are in ». * - * The link holds no id: the worker lists the invitations sent to the address this session proved, and - * only that session accepts one. Somebody who is not signed in is asked to sign in with the address - * the message came to. + * The link of an invitation holds no id and signs nobody in: the worker lists the invitations sent + * to the address this session proved, and only that session accepts one. Somebody who is not signed + * in is asked to sign in with the address the message came to. */ const ROLE_WORDS: Readonly> = { admin: msg('Admin'), member: msg('Member') }; +/** What a role lets its person do, said before they join. A role this screen does not know reads as a member. */ +const ROLE_MEANS: Readonly> = { + admin: msg('You join as an admin: you manage the sites, the connectors and the members of {workspace}.'), + member: msg('You join as a member: you see and leave notes on every site of {workspace}.'), +}; const ANSWER_PROBLEMS: Readonly> = { INVITATION_NOT_FOUND: msg('This invitation was cancelled, or it ended. Ask for another one.'), INVITER_NO_LONGER_INVITES: msg('This invitation was cancelled, or it ended. Ask for another one.'), }; const NOT_ANSWERED = msg('The invitation could not be answered. Try again.'); +const STILL_SIGNED_IN = msg('You are still signed in: Fruitback did not confirm the sign-out. Try again.'); + +/** + * Where the extension is explained. It is in no store yet (FRU-130): until it is, « Add to Chrome » + * and « Get the Firefox add-on » lead to the page that says how to install it. + */ +const EXTENSION_GUIDE = 'https://fruitback.com/reviewing.html#1-install-it'; + +type Read = + | { kind: 'loading' } + | { kind: 'signed-out' } + | { kind: 'failed' } + | { kind: 'listed'; email: string; invitations: Invitation[] } + /** Joined: the workspace, and its first site when it has one to name. */ + | { kind: 'joined'; workspace: string; name: string; site?: Site }; + +/** The name of the workspace of an invitation, or its id for a worker that does not say the name. */ +function nameOf(invitation: Invitation): string { + return invitation.organizationName ?? invitation.organizationId; +} -type Read = { email: string; invitations: Invitation[] } | 'loading' | 'signed-out' | 'failed'; +/** The most recent first: the one the message that was just opened is about. */ +function latestFirst(invitations: Invitation[]): Invitation[] { + const sent = (invitation: Invitation) => Date.parse(invitation.createdAt ?? invitation.expiresAt); + + return [...invitations].sort((one, other) => sent(other) - sent(one)); +} export default function Invitations() { useLocale(); const navigate = useNavigate(); - const [read, setRead] = useState('loading'); + const [read, setRead] = useState({ kind: 'loading' }); const [stuck, setStuck] = useState<{ id: string; problem: string } | undefined>(); const [busy, setBusy] = useState(); const load = useCallback(() => { - setRead('loading'); + setRead({ kind: 'loading' }); return callUntilAnswered('GET', '/console/me', (me) => { - if (!me.ok) return setRead('signed-out'); + if (!me.ok) return setRead({ kind: 'signed-out' }); void myInvitations().then((invitations) => - setRead(invitations.ok ? { email: me.data.account.email, invitations: invitations.data } : 'failed'), + setRead( + invitations.ok + ? { kind: 'listed', email: me.data.account.email, invitations: latestFirst(invitations.data) } + : { kind: 'failed' }, + ), ); }); }, []); @@ -49,130 +93,281 @@ export default function Invitations() { setBusy(invitation.id); const route = accept ? 'accept-invitation' : 'reject-invitation'; const answered = await call('POST', `${ORGANIZATION}/${route}`, { invitationId: invitation.id }); - setBusy(undefined); - if (!answered.ok) + if (!answered.ok) { + setBusy(undefined); + return setStuck({ id: invitation.id, problem: problemOf(answered, ANSWER_PROBLEMS, NOT_ANSWERED) }); - if (accept) return navigate(`/w/${invitation.organizationId}/sites`, { replace: true }); - setRead((current) => - typeof current === 'string' - ? current - : { ...current, invitations: current.invitations.filter((each) => each.id !== invitation.id) }, - ); + } + if (!accept) { + setBusy(undefined); + + return setRead((current) => + current.kind === 'listed' + ? { ...current, invitations: current.invitations.filter((each) => each.id !== invitation.id) } + : current, + ); + } + // The first site of the workspace, to name it in what comes next. A list that could not be + // read names none: the screen then speaks of the sites of the workspace. + const sites = await call<{ sites: Site[] }>('GET', `/console/workspaces/${invitation.organizationId}/sites`); + const site = sites.ok ? sites.data.sites[0] : undefined; + setBusy(undefined); + setRead({ + kind: 'joined', + workspace: invitation.organizationId, + name: nameOf(invitation), + ...(site === undefined ? {} : { site }), + }); + } + + /** « Use another address »: the session ends, and the sign-in page is for the other address. */ + async function another(invitation: Invitation) { + setStuck(undefined); + if (await signOut()) return navigate('/sign-in', { replace: true }); + // A session that did not end is said: the person believes they left this account. + setStuck({ id: invitation.id, problem: STILL_SIGNED_IN }); } return ( -
-
-
- - {t('Fruitback')} -
-

{t('Invitations')}

- + {read.kind === 'loading' ?

: null} + {read.kind === 'signed-out' ? ( + {t('Sign in')}} + /> + ) : null} + {read.kind === 'failed' ? ( + + {t('Try again')} + + } + /> + ) : null} + {read.kind === 'listed' && read.invitations.length === 0 ? ( + + {t('Go to the console')} + + } + /> + ) : null} + {read.kind === 'listed' && read.invitations[0] !== undefined ? ( + void answer(each, accept)} - onRetry={load} + onAnother={(each) => void another(each)} /> -

-
+ ) : null} + {read.kind === 'joined' ? : null} + ); } -function Body({ - read, - busy, - stuck, - onAnswer, - onRetry, +/** A state with nothing to join: a title, what happened, and the one way on. */ +function Note({ + title, + lead, + action, + alert = false, }: { - read: Read; + title: string; + lead: string; + action: ReactNode; + alert?: boolean; +}) { + return ( + <> +
+ {title} +
+

+ {lead} +

+
{action}
+ + ); +} + +type InvitedProps = { + email: string; + invitation: Invitation; + others: Invitation[]; busy: string | undefined; stuck: { id: string; problem: string } | undefined; onAnswer: (invitation: Invitation, accept: boolean) => void; - onRetry: () => void; -}) { + onAnother: (invitation: Invitation) => void; +}; + +/** The invitation on the screen, and under it the others that wait for the same address. */ +function Invited({ email, invitation, others, busy, stuck, onAnswer, onAnother }: InvitedProps) { useLocale(); - if (read === 'loading') return

{t('Loading…')}

; - if (read === 'signed-out') - return ( - <> -

- {t('Sign in with the address the invitation was sent to, and it shows here.')} -

- - {t('Sign in')} - - - ); - if (read === 'failed') - return ( -
-

- {t('Fruitback did not answer. Your invitations are not lost.')} -

- -
- ); - if (read.invitations.length === 0) - return ( - <> -

- {t('No invitation waits for {email}. An invitation sent to another address shows to that address only.', { - email: read.email, - })} -

- - {t('Go to the console')} - - - ); + const workspace = nameOf(invitation); return ( <> -

{t('For {email}.', { email: read.email })}

- -
    - {read.invitations.map((invitation) => ( -
  • -
    - - - {invitation.organizationName ?? invitation.organizationId} - - - {t('Until {date}', { date: new Date(invitation.expiresAt).toLocaleDateString() })} +

    + + + {withStrong(msg('{workspace} invited you'), 'workspace', workspace, 'text-ink')} + +

    +
    + {t('Join {workspace} on Fruitback', { workspace })} +
    + {t(ROLE_MEANS[invitation.role] ?? (ROLE_MEANS.member as string), { workspace })} + +
    + +
    + + + {stuck?.id === invitation.id ? ( +
    + {t(stuck.problem)} +
    + ) : null} +
    +

    + {t('Not {email}?', { email })}{' '} + onAnother(invitation)}> + {t('Use another address')} + +

    + onAnswer(invitation, false)}> + {t('Decline')} + +
    + + {others.length === 0 ? null : ( +
    +

    + {others.length === 1 ? t('One more invitation') : t('{count} more invitations', { count: others.length })} +

    +
      + {others.map((other) => ( +
    • +
      + + {nameOf(other)} + {t(ROLE_WORDS[other.role] ?? msg('Member'))} - - {t(ROLE_WORDS[invitation.role] ?? msg('Member'))} - - - - -
      - {stuck?.id === invitation.id ? ( -
      - {t(stuck.problem)}
      - ) : null} -
    • - ))} -
    - + {stuck?.id === other.id ? ( +
    + {t(stuck.problem)} +
    + ) : null} +
  • + ))} +
+ + )} + + ); +} + +/** The arrow of a link that leaves the console. */ +function Away() { + return ( + + ); +} + +/** + * Joined: what comes next is the extension, which shows the notes on the page. + * + * The mockup goes on with « It signs in with this account. ». That is not so today: a browser is + * connected from the console, with a code. The sentence waits for the decision on that (Q8 of + * FRU-230), and the way to the console is here in its place. + */ +function Joined({ workspace, name, site }: { workspace: string; name: string; site: Site | undefined }) { + useLocale(); + const host = site === undefined ? undefined : new URL(site.origin).host; + // Not on the mockup, which leaves no way on but the extension: the console is where the person + // connects their browser today. + const console = ( + + {t('Go to the console')} + + ); + + return ( + <> +
+ +
+
+ {t('You are in')} +
+

+ {host === undefined + ? t('One more thing: the extension shows the notes on the sites of {workspace} and lets you leave yours.', { + workspace: name, + }) + : t('One more thing: the extension shows the notes on {site} and lets you leave yours.', { site: host })} +

+
+ + {site === undefined || host === undefined ? ( + console + ) : ( + + {t('Open {site}', { site: host })} + + )} +
+

+ {t('Using Firefox?')}{' '} + + {t('Get the Firefox add-on')} + +

+ {/* With a site to open beside the button, the way to the console has its own line. */} + {site === undefined ? null :

{console}

} ); } diff --git a/docs/decisions/console.md b/docs/decisions/console.md index 7bbbc051..01a98a11 100644 --- a/docs/decisions/console.md +++ b/docs/decisions/console.md @@ -115,6 +115,20 @@ The console of the Cloud, `apps/console`: its words, its kit, and what it shows covers (FRU-103, decided on 2026-10-13), and the worker takes no pattern as an address. So what is under the hint is 19.5 px higher than on the mockup. The last step keeps the script and the browser of `install-options.tsx` until FRU-242 draws it. +- **An invitation has a screen, and joining says what comes next** (FRU-241, screen 03 of the mockup + of 2026-10-13). `/invitations` shows the most recent invitation of the address that is signed in: + the workspace that invites, the role and what it lets its person do, « Join », and « Decline » + beside it. Others that wait are listed under it. Joined, the screen stays (« You are in »), names + the first site of the workspace when it has one, and leads to the console. **The link of an + invitation still signs nobody in** (`SECURITY.md`), so the line under the button is « Not + {email}? Use another address », which ends the session and leads to the sign-in; the mockup's + « The link in the invitation signed you in. » is not said. **Three things the mockup has and this + screen does not, each for a decision that is open** (FRU-230): the name of who invites (the worker + lists the workspace of an invitation, not its sender: the line says « {workspace} invited you »); + the guest of one site, with its title and its row « Site » (Q3); « It signs in with this + account. » about the extension, which a browser still joins with a code from the console (Q8). + **The extension is in no store yet** (FRU-130): « Add to Chrome » and « Get the Firefox add-on » + lead to the page of the documentation that says how to install it. - **The address of a site is read by `site-address.ts`, on every screen that asks for one** (FRU-167). A host with no scheme is `https://`, as in the extension. The field is not `type="url"`: the browser stopped the form with its own bubble, in its own language, and the sentence of the diff --git a/e2e/console-invitation.spec.ts b/e2e/console-invitation.spec.ts index 5bf238a1..6c1141b9 100644 --- a/e2e/console-invitation.spec.ts +++ b/e2e/console-invitation.spec.ts @@ -1,5 +1,7 @@ -import { type BrowserContext, type Page, expect, test } from '@playwright/test'; +import AxeBuilder from '@axe-core/playwright'; +import { type BrowserContext, type Locator, type Page, expect, test } from '@playwright/test'; import { CONSOLE, type Worker, cloudWorker, finished, served } from './cloud-worker.ts'; +import { WORKER_ORIGIN } from './pin.ts'; /** * An invited reviewer, from the message they received to their first note (FRU-104). @@ -109,8 +111,19 @@ test.describe('an invited reviewer (FRU-104)', () => { // No workspace name: Paul comes to join one. await signIn(paul, worker, 'paul@client.dev'); await expect(paul).toHaveURL(`${CONSOLE}/invitations`); - await expect(paul.getByText('Acme', { exact: true })).toBeVisible(); + // The screen of the invitation (FRU-241): who invites, the role, and the address that joins. + await expect(paul.getByRole('heading', { name: 'Join Acme on Fruitback' })).toBeVisible(); + await expect(paul.getByText('Acme invited you')).toBeVisible(); + await expect(paul.getByText('You join as a member: you see and leave notes on every site of Acme.')).toBeVisible(); + await expect(paul.locator('dl')).toHaveText('Signed in aspaul@client.devRoleMember'); await paul.getByRole('button', { name: 'Join Acme' }).click(); + // Joined: the screen says so, names the site of the workspace, and leads to the console. + await expect(paul.getByRole('heading', { name: 'You are in' })).toBeVisible(); + await expect(paul.getByRole('status')).toHaveText( + 'One more thing: the extension shows the notes on staging.acme.dev and lets you leave yours.', + ); + await expect(paul.getByRole('link', { name: 'Open staging.acme.dev' })).toHaveAttribute('href', SITE); + await paul.getByRole('link', { name: 'Go to the console' }).click(); await expect(paul).toHaveURL(`${CONSOLE}/w/${workspace}/sites`); await expect(paul.getByText('staging.acme.dev', { exact: true })).toBeVisible(); @@ -211,3 +224,320 @@ test.describe('an invited reviewer (FRU-104)', () => { await expect(owner.getByText('Invitation sent', { exact: true })).toBeVisible(); }); }); + +/** What the worker answers about the invitations of the person, for the second part: stubs. */ +type Stub = { + as?: 'nobody' | 'somebody'; + invitations?: + | { id: string; role: string; organizationId: string; organizationName: string; createdAt: string }[] + | 'failed'; + /** What the worker answers to « Join » and to « Decline ». */ + answer?: { status: number; body: unknown }; + sites?: string[]; + signsOut?: boolean; + locale?: 'en' | 'fr'; +}; + +/** + * The sample words are as wide as the mockup's in the system font, without copying its sample data: + * « Havane » for the workspace. The address is the mockup's own example, which is on `main` already. + */ +const INVITED = 'camille@acme.dev'; +const HAVANE = { + id: 'i1', + role: 'member', + organizationId: 'w1', + organizationName: 'Havane', + createdAt: '2026-10-12T08:00:00.000Z', +}; + +async function stubWorker(page: Page, stub: Stub = {}): Promise { + const asked: string[] = []; + let signedIn = stub.as !== 'nobody'; + await page.context().route(`${WORKER_ORIGIN}/**`, (route) => { + const request = route.request(); + const { pathname } = new URL(request.url()); + asked.push(`${request.method()} ${pathname}`); + const json = (body: unknown, status = 200) => route.fulfill({ status, json: body }); + if (pathname === '/auth/providers') return json({ betterAuth: { email: true, github: false, google: false } }); + if (pathname === '/console/me') { + return signedIn + ? json({ account: { id: 'a1', email: INVITED, name: 'Camille', locale: stub.locale ?? 'en' }, workspaces: [] }) + : json({ error: 'identity-required' }, 401); + } + if (pathname.endsWith('/list-user-invitations')) { + if (stub.invitations === 'failed') return json({ error: 'unavailable' }, 503); + + return json( + (stub.invitations ?? [HAVANE]).map((each) => ({ + ...each, + email: INVITED, + status: 'pending', + expiresAt: '2026-12-01T08:00:00.000Z', + })), + ); + } + if (pathname.endsWith('/accept-invitation') || pathname.endsWith('/reject-invitation')) { + return json(stub.answer?.body ?? {}, stub.answer?.status ?? 200); + } + if (pathname === '/console/workspaces/w1/sites') { + return json({ + sites: (stub.sites ?? []).map((origin, index) => ({ id: `s${index}`, origin, visibility: 'members' })), + }); + } + if (pathname === '/console/auth/sign-out') { + if (stub.signsOut === false) return json({ error: 'unavailable' }, 503); + signedIn = false; + + return json({ success: true }); + } + + return route.fulfill({ status: 404, json: { error: 'not-found' } }); + }); + + return asked; +} + +type Box = { x: number; y: number; width: number; height: number }; + +async function boxOf(locator: Locator): Promise { + const box = (await locator.first().boundingBox()) as Box; + const round = (value: number) => Math.round(value * 10) / 10; + + return { x: round(box.x), y: round(box.y), width: round(box.width), height: round(box.height) }; +} + +/** + * The mockup is measured in the system font of macOS: a line of text with no line height of its own + * is 1 or 2 px taller in the system font of Linux, which the CI draws, and a word is not as wide. + * So the boxes are compared where the font is the mockup's. Everything else is measured everywhere. + */ +const FONT_OF_THE_MOCKUP = process.platform === 'darwin'; + +function expectNear(built: Box, mockup: Box, name: string, tolerance = 2): void { + if (!FONT_OF_THE_MOCKUP) return; + for (const side of ['x', 'y', 'width', 'height'] as const) { + expect + .soft(Math.abs(built[side] - mockup[side]), `${name}: ${side} is ${built[side]}, the mockup has ${mockup[side]}`) + .toBeLessThanOrEqual(tolerance); + } +} + +const violations = async (page: Page): Promise => + (await new AxeBuilder({ page }).analyze()).violations.map( + (violation) => `${violation.id}: ${violation.nodes[0]?.target.join(' ')}`, + ); + +test.describe('the screen of an invitation, against the mockup (FRU-241)', () => { + // The boxes of the mockup at 1440 × 900, read in its `measures.json`. Its title has two lines and + // its list three rows, for a guest of one site, a role that does not exist yet: here the title has + // one line (32.2 px less) and the list two rows (43 px less). + test('the invitation: the boxes of the mockup at 1440 px, less what a guest of one site adds', async ({ page }) => { + await stubWorker(page); + await page.goto(`${CONSOLE}/invitations`); + const join = page.getByRole('button', { name: 'Join Havane' }); + await expect(join).toBeVisible(); + + expectNear( + await boxOf(page.getByRole('img', { name: 'Fruitback' })), + { x: 80, y: 21, width: 113, height: 22 }, + 'logo', + ); + expectNear(await boxOf(page.locator('.kit-avatar')), { x: 500, y: 155, width: 28, height: 28 }, 'avatar'); + const title = page.getByRole('heading', { level: 1 }); + await expect(title).toHaveText('Join Havane on Fruitback'); + expectNear(await boxOf(title), { x: 500, y: 203, width: 440, height: 32.2 }, 'title'); + expectNear(await boxOf(join), { x: 500, y: 507 - 32.2 - 43, width: 440, height: 44 }, '« Join »'); + await expect(page.locator('dl > div')).toHaveCount(2); + await expect(page.locator('dl')).toHaveText(`Signed in as${INVITED}RoleMember`); + // The line under the button says nothing of a link that signs in: the link of an invitation + // signs nobody in (the decision on that is open, Q2 of FRU-230). + await expect(page.getByText(`Not ${INVITED}?`)).toBeVisible(); + await expect(page.getByText(/signed you in/)).toHaveCount(0); + await expect(page.getByRole('button', { name: 'Decline' })).toBeVisible(); + expect(await violations(page)).toEqual([]); + }); + + test('joined: the boxes of the mockup at 1440 px, and the confirmation is said', async ({ page }) => { + await stubWorker(page, { sites: ['https://staging.acme.dev'] }); + await page.goto(`${CONSOLE}/invitations`); + await page.getByRole('button', { name: 'Join Havane' }).click(); + await expect(page.getByRole('heading', { name: 'You are in' })).toBeVisible(); + + expectNear(await boxOf(page.locator('.kit-success')), { x: 504, y: 155, width: 40, height: 40 }, 'success mark'); + expectNear( + await boxOf(page.getByRole('heading', { level: 1 })), + { x: 500, y: 219, width: 440, height: 32.2 }, + 'title', + ); + // The sentence of the mockup has three lines; without « It signs in with this account. » it has + // two, so what follows is 24 px higher. + const status = page.getByRole('status'); + await expect(status).toHaveText( + 'One more thing: the extension shows the notes on staging.acme.dev and lets you leave yours.', + ); + expectNear(await boxOf(status), { x: 500, y: 261, width: 440, height: 48 }, 'confirmation'); + const add = page.getByRole('link', { name: 'Add to Chrome' }); + expectNear(await boxOf(add), { x: 500, y: 361 - 24, width: 167.1, height: 44 }, '« Add to Chrome »'); + expectNear( + await boxOf(page.getByRole('link', { name: 'Open staging.acme.dev' })), + { x: 687, y: 363 - 24, width: 159.9, height: 41 }, + '« Open the site »', + ); + expectNear( + await boxOf(page.getByRole('link', { name: 'Get the Firefox add-on' })), + { x: 595, y: 454 - 24, width: 151.8, height: 17 }, + 'Firefox', + ); + // The extension is in no store yet: both links lead to the page that says how to install it. + for (const link of [add, page.getByRole('link', { name: 'Get the Firefox add-on' })]) { + await expect(link).toHaveAttribute('href', 'https://fruitback.com/reviewing.html#1-install-it'); + await expect(link).toHaveAttribute('target', '_blank'); + } + await expect(page.getByText(/signs in with this account/)).toHaveCount(0); + await expect(page.getByRole('link', { name: 'Go to the console' })).toHaveAttribute('href', '/w/w1/sites'); + expect(await violations(page)).toEqual([]); + }); + + test('joined a workspace with no site: the sentence names the workspace, and nothing opens a site', async ({ + page, + }) => { + await stubWorker(page, { sites: [] }); + await page.goto(`${CONSOLE}/invitations`); + await page.getByRole('button', { name: 'Join Havane' }).click(); + + await expect(page.getByRole('status')).toHaveText( + 'One more thing: the extension shows the notes on the sites of Havane and lets you leave yours.', + ); + await expect(page.getByRole('link', { name: /^Open / })).toHaveCount(0); + await expect(page.getByRole('link', { name: 'Go to the console' })).toHaveCount(1); + }); + + test('several invitations: the most recent on the screen, the others under it, each to join or decline', async ({ + page, + }) => { + const asked = await stubWorker(page, { + invitations: [ + { + ...HAVANE, + id: 'old', + organizationName: 'Lumen', + organizationId: 'w2', + createdAt: '2026-10-01T08:00:00.000Z', + }, + HAVANE, + { + ...HAVANE, + id: 'older', + role: 'admin', + organizationName: 'Client', + organizationId: 'w3', + createdAt: '2026-09-01T08:00:00.000Z', + }, + ], + }); + await page.goto(`${CONSOLE}/invitations`); + + await expect(page.getByRole('heading', { level: 1 })).toHaveText('Join Havane on Fruitback'); + const more = page.getByRole('region', { name: '2 more invitations' }); + await expect(more.getByRole('listitem')).toHaveText([/^LumenMember/, /^ClientAdmin/]); + await more.getByRole('listitem').filter({ hasText: 'Client' }).getByRole('button', { name: 'Decline' }).click(); + await expect(page.getByRole('region', { name: 'One more invitation' }).getByRole('listitem')).toHaveText([ + /^LumenMember/, + ]); + expect(asked.filter((each) => each.includes('reject-invitation'))).toHaveLength(1); + expect(await violations(page)).toEqual([]); + }); + + test('an invitation that ended says so, and stays on the screen', async ({ page }) => { + await stubWorker(page, { answer: { status: 400, body: { code: 'INVITATION_NOT_FOUND' } } }); + await page.goto(`${CONSOLE}/invitations`); + await page.getByRole('button', { name: 'Join Havane' }).click(); + + await expect(page.getByRole('alert')).toHaveText( + 'This invitation was cancelled, or it ended. Ask for another one.', + ); + await expect(page.getByRole('button', { name: 'Join Havane' })).toBeEnabled(); + }); + + test('« Use another address » ends the session and leads to the sign-in, or says that it did not', async ({ + page, + }) => { + // The worker does not end the session: the screen stays, and says so. + await stubWorker(page, { signsOut: false }); + await page.goto(`${CONSOLE}/invitations`); + await page.getByRole('button', { name: 'Use another address' }).click(); + await expect(page.getByRole('alert')).toHaveText( + 'You are still signed in: Fruitback did not confirm the sign-out. Try again.', + ); + await expect(page.getByRole('button', { name: 'Join Havane' })).toBeVisible(); + + await page.context().unrouteAll({ behavior: 'ignoreErrors' }); + const asked = await stubWorker(page); + await page.goto(`${CONSOLE}/invitations`); + await page.getByRole('button', { name: 'Use another address' }).click(); + await expect(page).toHaveURL(`${CONSOLE}/sign-in`); + await expect(page.getByRole('heading', { name: 'Sign in to Fruitback' })).toBeVisible(); + expect(asked).toContain('POST /console/auth/sign-out'); + }); + + test('nobody signed in, no invitation, and a worker that did not answer each have their sentence', async ({ + page, + }) => { + await stubWorker(page, { as: 'nobody' }); + await page.goto(`${CONSOLE}/invitations`); + await expect( + page.getByText('Sign in with the address the invitation was sent to, and it shows here.'), + ).toBeVisible(); + await expect(page.getByRole('link', { name: 'Sign in' })).toHaveAttribute('href', '/sign-in'); + expect(await violations(page)).toEqual([]); + + await page.context().unrouteAll({ behavior: 'ignoreErrors' }); + await stubWorker(page, { invitations: [] }); + await page.goto(`${CONSOLE}/invitations`); + await expect( + page.getByText( + `No invitation waits for ${INVITED}. An invitation sent to another address shows to that address only.`, + ), + ).toBeVisible(); + await expect(page.getByRole('link', { name: 'Go to the console' })).toHaveAttribute('href', '/'); + + await page.context().unrouteAll({ behavior: 'ignoreErrors' }); + await stubWorker(page, { invitations: 'failed' }); + await page.goto(`${CONSOLE}/invitations`); + await expect(page.getByRole('alert')).toHaveText('Fruitback did not answer. Your invitations are not lost.'); + await expect(page.getByRole('button', { name: 'Try again' })).toBeVisible(); + }); + + for (const locale of ['en', 'fr'] as const) { + test(`the invitation and « You are in » fit a phone, in ${locale}`, async ({ browser }) => { + const context = await browser.newContext({ + locale: locale === 'en' ? 'en-US' : 'fr-FR', + viewport: { width: 390, height: 844 }, + }); + const page = await context.newPage(); + const long = 'A workspace with a very long name that somebody typed in a hurry'; + await stubWorker(page, { + locale, + invitations: [{ ...HAVANE, organizationName: long }], + sites: ['https://preview-branch-feature-very-long-name-1234.staging.acme.dev'], + }); + await page.goto(`${CONSOLE}/invitations`); + const join = page.getByRole('button', { name: locale === 'en' ? `Join ${long}` : `Rejoindre ${long}` }); + await expect(page.getByRole('heading', { level: 1 })).toBeVisible(); + const sideScroll = () => + page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth); + for (const width of [390, 320]) { + await page.setViewportSize({ width, height: 844 }); + expect.soft(await sideScroll(), `the invitation scrolls sideways at ${width} px`).toBe(0); + } + await join.click(); + await expect(page.getByRole('status')).toBeVisible(); + for (const width of [390, 320]) { + await page.setViewportSize({ width, height: 844 }); + expect.soft(await sideScroll(), `« You are in » scrolls sideways at ${width} px`).toBe(0); + } + await context.close(); + }); + } +}); From 2ec3accd148cf13402387b3bf9ad26b594162890 Mon Sep 17 00:00:00 2001 From: Mathieu Audebert Date: Tue, 13 Oct 2026 18:21:44 +0200 Subject: [PATCH 2/2] =?UTF-8?q?test(console):=20=C2=AB=20You=20are=20in=20?= =?UTF-8?q?=C2=BB=20when=20the=20sites=20of=20the=20workspace=20cannot=20b?= =?UTF-8?q?e=20read=20(FRU-241)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After « Join », the console reads the sites of the workspace to name the first one. Two cases hold what happens when it cannot: the worker refuses the list, or the request does not arrive. The invitation is accepted, the screen says « You are in » and names no site, and nothing stays busy. Answers the balanced review of c43a272. Co-Authored-By: Claude Opus 5.5 --- e2e/console-invitation.spec.ts | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/e2e/console-invitation.spec.ts b/e2e/console-invitation.spec.ts index 6c1141b9..b81858b3 100644 --- a/e2e/console-invitation.spec.ts +++ b/e2e/console-invitation.spec.ts @@ -233,7 +233,8 @@ type Stub = { | 'failed'; /** What the worker answers to « Join » and to « Decline ». */ answer?: { status: number; body: unknown }; - sites?: string[]; + /** `failed`: the worker refuses the list. `lost`: the request does not arrive. */ + sites?: string[] | 'failed' | 'lost'; signsOut?: boolean; locale?: 'en' | 'fr'; }; @@ -281,6 +282,9 @@ async function stubWorker(page: Page, stub: Stub = {}): Promise { return json(stub.answer?.body ?? {}, stub.answer?.status ?? 200); } if (pathname === '/console/workspaces/w1/sites') { + if (stub.sites === 'failed') return json({ error: 'unavailable' }, 503); + if (stub.sites === 'lost') return route.abort('connectionfailed'); + return json({ sites: (stub.sites ?? []).map((origin, index) => ({ id: `s${index}`, origin, visibility: 'members' })), }); @@ -413,6 +417,24 @@ test.describe('the screen of an invitation, against the mockup (FRU-241)', () => await expect(page.getByRole('link', { name: 'Go to the console' })).toHaveCount(1); }); + for (const sites of ['failed', 'lost'] as const) { + test(`joined, and the sites cannot be read (${sites}): « You are in » all the same, and nothing stays busy`, async ({ + page, + }) => { + const asked = await stubWorker(page, { sites }); + await page.goto(`${CONSOLE}/invitations`); + await page.getByRole('button', { name: 'Join Havane' }).click(); + + // The invitation is accepted: the screen says so, and names no site it could not read. + await expect(page.getByRole('heading', { name: 'You are in' })).toBeVisible(); + await expect(page.getByRole('status')).toHaveText( + 'One more thing: the extension shows the notes on the sites of Havane and lets you leave yours.', + ); + await expect(page.getByRole('link', { name: 'Go to the console' })).toHaveCount(1); + expect(asked).toContain('GET /console/workspaces/w1/sites'); + }); + } + test('several invitations: the most recent on the screen, the others under it, each to join or decline', async ({ page, }) => {