From ede0d4188a3ff17d14c965e2fd0245f541677cf7 Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Wed, 24 Jun 2026 09:07:36 -0300 Subject: [PATCH 1/3] fix(onboarding): preserve tags when renaming the flag Feature names are immutable, so the onboarding "rename" is a delete + recreate. The recreate started a bare flag, dropping the Onboarding tag attached in bootstrap (the badge vanished). Carry over the old flag's tags/type/description so the rename is non-lossy for what onboarding sets. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../onboarding/hooks/useOnboardingFlagRename.ts | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/frontend/web/components/pages/onboarding/hooks/useOnboardingFlagRename.ts b/frontend/web/components/pages/onboarding/hooks/useOnboardingFlagRename.ts index 62e5baa1a9e6..d93468590ab2 100644 --- a/frontend/web/components/pages/onboarding/hooks/useOnboardingFlagRename.ts +++ b/frontend/web/components/pages/onboarding/hooks/useOnboardingFlagRename.ts @@ -12,11 +12,14 @@ type UseOnboardingFlagRenameArgs = { featureName: string } -// "Rename" the onboarding flag. Feature names are immutable once created, so -// this is a delete + recreate under the hood - safe here because the flag is -// freshly bootstrapped and not yet depended on. Recreate first (names differ, -// so no unique conflict) and only then drop the old one, so a flag always -// exists even if the second call fails. Resolves true on success. +// "Rename" the onboarding flag. Feature names are immutable once created +// (UpdateFeatureSerializer marks `name` read-only), so this is a delete + +// recreate under the hood - safe here because the flag is freshly bootstrapped +// and not yet depended on. Recreate first (names differ, so no unique conflict) +// and only then drop the old one, so a flag always exists even if the second +// call fails. The recreate carries over the old flag's tags/type/description so +// the Onboarding badge (and any other tag) survives the rename. Resolves true +// on success. // // The toggle that will also act on this flag lives in #7766 (the flags table); // this hook is intentionally rename-only so the connect-panel issue doesn't pull @@ -45,9 +48,11 @@ export const useOnboardingFlagRename = ({ try { await createProjectFlag({ body: { + description: flag.description, name, project: projectId, - type: 'STANDARD', + tags: flag.tags, + type: flag.type, } as Req['createProjectFlag']['body'], project_id: projectId, }).unwrap() From 5dbd9611067bbed546817682da5056505feb4cef Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Wed, 24 Jun 2026 10:05:22 -0300 Subject: [PATCH 2/3] feat(onboarding): accessible names for the copy buttons and flag switch The two "Copy Code" buttons were indistinguishable to assistive tech, the flags table toggle had no accessible name, and the table wasn't a labelled region. Give the copy buttons distinct aria-labels, name the switch per flag, and label the flags region. Doubles as stable a11y-based handles for e2e (no test-ids). Co-Authored-By: Claude Opus 4.8 (1M context) --- frontend/e2e/tests/onboarding-tests.pw.ts | 115 ++++++++++++++++++ .../OnboardingConnectPanel/CodeCard.tsx | 6 + .../ConnectYourCodePanel.tsx | 2 + .../OnboardingFlagsTable.tsx | 10 +- 4 files changed, 131 insertions(+), 2 deletions(-) create mode 100644 frontend/e2e/tests/onboarding-tests.pw.ts diff --git a/frontend/e2e/tests/onboarding-tests.pw.ts b/frontend/e2e/tests/onboarding-tests.pw.ts new file mode 100644 index 000000000000..2d8662705849 --- /dev/null +++ b/frontend/e2e/tests/onboarding-tests.pw.ts @@ -0,0 +1,115 @@ +import { test, expect } from '../test-setup'; +import { + byId, + createHelpers, + getFlagsmith, + log, + visualSnapshot, +} from '../helpers'; +import { E2E_SIGN_UP_USER, PASSWORD } from '../config'; + +// The single-page onboarding flow (onboarding_quickstart_flow) a new user lands +// on at /getting-started. Mirror of the legacy signup test's guard: this runs +// only when the flag is on, the legacy signup test runs only when it's off. +test.describe('Onboarding', () => { + test('New user connects via the single-page onboarding flow @oss', async ({ + page, + }, testInfo) => { + const { addErrorLogging, click, setText, waitForElementVisible } = + createHelpers(page); + const flagsmith = await getFlagsmith(); + + test.skip( + !flagsmith.hasFeature('onboarding_quickstart_flow'), + 'Onboarding flow is behind onboarding_quickstart_flow', + ); + + await addErrorLogging(); + + // Sign up a fresh user; with the flag on, a getting-started user is routed + // to /getting-started where the flow bootstraps the org / project / flag. + log('Sign up'); + await page.goto('/'); + await click(byId('jsSignup')); + await waitForElementVisible(byId('firstName')); + await setText(byId('firstName'), 'Bullet'); + await setText(byId('lastName'), 'Train'); + await setText(byId('email'), E2E_SIGN_UP_USER); + await setText(byId('password'), PASSWORD); + await click(byId('signup-btn')); + + // Land on the flow. Navigating explicitly is robust to the exact post-signup + // redirect; the gate + idempotent bootstrap render the flow either way. + log('Land on the onboarding flow'); + await page.goto('/getting-started'); + await waitForElementVisible(byId('onboarding-flow'), 30000); + await visualSnapshot(page, 'onboarding-flow', testInfo); + + // The verify terminal starts pre-connection: LISTENING, nothing ticked. + await expect(page.locator(byId('onboarding-terminal-badge'))).toHaveText( + /LISTENING/, + ); + await expect(page.locator(byId('onboarding-step-0'))).toHaveAttribute( + 'data-done', + 'false', + ); + + // Copying the install + wire snippets ticks the checklist. + log('Copy snippets, checklist ticks'); + await click(byId('onboarding-copy-install')); + await expect(page.locator(byId('onboarding-step-0'))).toHaveAttribute( + 'data-done', + 'true', + ); + await click(byId('onboarding-copy-wire')); + await expect(page.locator(byId('onboarding-step-1'))).toHaveAttribute( + 'data-done', + 'true', + ); + + // The Development toggle is real and persists (updateFeatureState). + log('Toggle the flag'); + const flagSwitch = page.locator(byId('onboarding-flag-switch')); + await flagSwitch.waitFor({ state: 'visible' }); + const wasChecked = (await flagSwitch.getAttribute('class'))?.includes( + 'switch-checked', + ); + await flagSwitch.click(); + await expect(flagSwitch).toHaveClass( + wasChecked ? /switch-unchecked/ : /switch-checked/, + ); + + // The Onboarding badge (attached in bootstrap) shows in the flags table. + // Scope + exact: the header crumb also contains the word "Onboarding". + const flagsTable = page.locator('.onboarding-flags'); + await expect( + flagsTable.getByText('Onboarding', { exact: true }), + ).toBeVisible(); + + // Rename the flag. Names are immutable, so this delete + recreates; the + // Onboarding tag must survive (the recreate carries the old flag's tags). + log('Rename the flag'); + const flagInput = page.getByLabel('Flag name'); + await flagInput.fill('renamed_demo_flag'); + await flagInput.press('Enter'); + + // Reload to prove the rename persisted server-side and the tag came with it + // (bootstrap is idempotent and reuses the renamed flag on revisit). + await page.reload(); + await waitForElementVisible(byId('onboarding-flow'), 30000); + await expect(page.getByLabel('Flag name')).toHaveValue('renamed_demo_flag'); + await expect( + page.locator('.onboarding-flags').getByText('Onboarding', { exact: true }), + ).toBeVisible(); + await visualSnapshot(page, 'onboarding-renamed', testInfo); + + // The connected state is stubbed behind ?connected pending #7767; it flips + // the badge to LIVE so the connected UI is exercised end to end. + log('Connected state'); + await page.goto('/getting-started?connected'); + await waitForElementVisible(byId('onboarding-flow'), 30000); + await expect(page.locator(byId('onboarding-terminal-badge'))).toHaveText( + /LIVE/, + ); + }); +}); diff --git a/frontend/web/components/pages/onboarding/OnboardingConnectPanel/CodeCard.tsx b/frontend/web/components/pages/onboarding/OnboardingConnectPanel/CodeCard.tsx index 276602c213e5..709c77faa35d 100644 --- a/frontend/web/components/pages/onboarding/OnboardingConnectPanel/CodeCard.tsx +++ b/frontend/web/components/pages/onboarding/OnboardingConnectPanel/CodeCard.tsx @@ -12,12 +12,17 @@ export type CodeCardProps = { headerLeft: ReactNode // Fires when the user copies the code (drives the verify checklist). onCopy?: () => void + // Accessible name for the copy button. The visible label is the same ("Copy + // Code") on every card, so this distinguishes them for screen readers (and + // gives a11y-based tests a stable handle); defaults to the visible label. + copyLabel?: string } // Owns its own "Copied" feedback so each card is independent. Highlight escapes // the body for display; Copy uses the raw string. const CodeCard: FC = ({ code, + copyLabel, headerLeft, language, onCopy, @@ -32,6 +37,7 @@ const CodeCard: FC = ({ theme='primary' size='small' className='ms-auto' + aria-label={copyLabel} onClick={() => { copy(code) onCopy?.() diff --git a/frontend/web/components/pages/onboarding/OnboardingConnectPanel/ConnectYourCodePanel.tsx b/frontend/web/components/pages/onboarding/OnboardingConnectPanel/ConnectYourCodePanel.tsx index 8268e0e1f3b0..b66882d24569 100644 --- a/frontend/web/components/pages/onboarding/OnboardingConnectPanel/ConnectYourCodePanel.tsx +++ b/frontend/web/components/pages/onboarding/OnboardingConnectPanel/ConnectYourCodePanel.tsx @@ -46,6 +46,7 @@ const ConnectYourCodePanel: FC = ({ code={installCode} language='bash' onCopy={onCopyInstall} + copyLabel='Copy install command' headerLeft={ sdkSnippet.installYarn ? (
@@ -81,6 +82,7 @@ const ConnectYourCodePanel: FC = ({ code={sdkSnippet.wire} language={sdkSnippet.language} onCopy={onCopyWire} + copyLabel='Copy code snippet' headerLeft={ {sdkLang.label} diff --git a/frontend/web/components/pages/onboarding/OnboardingFlagsTable/OnboardingFlagsTable.tsx b/frontend/web/components/pages/onboarding/OnboardingFlagsTable/OnboardingFlagsTable.tsx index b10cc80f3821..ea2ab514f309 100644 --- a/frontend/web/components/pages/onboarding/OnboardingFlagsTable/OnboardingFlagsTable.tsx +++ b/frontend/web/components/pages/onboarding/OnboardingFlagsTable/OnboardingFlagsTable.tsx @@ -37,8 +37,13 @@ const OnboardingFlagsTable: FC = ({ }) => { const waiting = status === 'waiting' return ( -
-

Your flags

+
+

+ Your flags +

= ({ checked={flag.enabled} disabled={togglingFlag === flag.name} onChange={(enabled) => onToggle(flag, enabled)} + aria-label={`Toggle ${flag.name}`} />
From a98d91f25dbc1e6cd175fc6f450eff375e87ac1d Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Wed, 24 Jun 2026 10:05:26 -0300 Subject: [PATCH 3/3] test(e2e): cover the single-page onboarding flow New-user happy path behind onboarding_quickstart_flow: sign up, land on the flow, copy the install/wire snippets (checklist ticks), toggle the Development flag, rename it (asserting the Onboarding tag survives the delete+recreate), and exercise the connected state via ?connected. Selectors are accessibility-first (roles / labels / text), not data-test ids. Co-Authored-By: Claude Opus 4.8 (1M context) --- frontend/e2e/tests/onboarding-tests.pw.ts | 72 +++++++++++------------ 1 file changed, 33 insertions(+), 39 deletions(-) diff --git a/frontend/e2e/tests/onboarding-tests.pw.ts b/frontend/e2e/tests/onboarding-tests.pw.ts index 2d8662705849..d08384aa0ddb 100644 --- a/frontend/e2e/tests/onboarding-tests.pw.ts +++ b/frontend/e2e/tests/onboarding-tests.pw.ts @@ -1,16 +1,14 @@ import { test, expect } from '../test-setup'; -import { - byId, - createHelpers, - getFlagsmith, - log, - visualSnapshot, -} from '../helpers'; +import { byId, createHelpers, getFlagsmith, log, visualSnapshot } from '../helpers'; import { E2E_SIGN_UP_USER, PASSWORD } from '../config'; // The single-page onboarding flow (onboarding_quickstart_flow) a new user lands // on at /getting-started. Mirror of the legacy signup test's guard: this runs // only when the flag is on, the legacy signup test runs only when it's off. +// +// Selectors are accessibility-first (roles / labels / text), not data-test ids: +// the header inputs expose aria-labels, the copy buttons and the flag switch +// carry accessible names, and the flags table is a labelled region. test.describe('Onboarding', () => { test('New user connects via the single-page onboarding flow @oss', async ({ page, @@ -26,6 +24,13 @@ test.describe('Onboarding', () => { await addErrorLogging(); + // The flow renders once bootstrap settles (it shows a loader, then an error + // heading on failure - so the welcome heading means "ready"). + const flowReady = async () => + page + .getByRole('heading', { name: /Welcome/ }) + .waitFor({ state: 'visible', timeout: 30000 }); + // Sign up a fresh user; with the flag on, a getting-started user is routed // to /getting-started where the flow bootstraps the org / project / flag. log('Sign up'); @@ -42,34 +47,26 @@ test.describe('Onboarding', () => { // redirect; the gate + idempotent bootstrap render the flow either way. log('Land on the onboarding flow'); await page.goto('/getting-started'); - await waitForElementVisible(byId('onboarding-flow'), 30000); + await flowReady(); await visualSnapshot(page, 'onboarding-flow', testInfo); // The verify terminal starts pre-connection: LISTENING, nothing ticked. - await expect(page.locator(byId('onboarding-terminal-badge'))).toHaveText( - /LISTENING/, - ); - await expect(page.locator(byId('onboarding-step-0'))).toHaveAttribute( - 'data-done', - 'false', - ); + await expect(page.getByText('LISTENING')).toBeVisible(); + await expect(page.getByText('Copy install command')).not.toContainText('✓'); - // Copying the install + wire snippets ticks the checklist. + // Copying the install + wire snippets ticks the checklist (the visible + // [✓] prefix is the done state). log('Copy snippets, checklist ticks'); - await click(byId('onboarding-copy-install')); - await expect(page.locator(byId('onboarding-step-0'))).toHaveAttribute( - 'data-done', - 'true', - ); - await click(byId('onboarding-copy-wire')); - await expect(page.locator(byId('onboarding-step-1'))).toHaveAttribute( - 'data-done', - 'true', - ); + await page.getByRole('button', { name: 'Copy install command' }).click(); + await expect(page.getByText('Copy install command')).toContainText('✓'); + await page.getByRole('button', { name: 'Copy code snippet' }).click(); + await expect(page.getByText('Copy code snippet')).toContainText('✓'); - // The Development toggle is real and persists (updateFeatureState). + // The Development toggle is real and persists (updateFeatureState). There + // are two switches on the page (theme + flag), so scope to the flags region. log('Toggle the flag'); - const flagSwitch = page.locator(byId('onboarding-flag-switch')); + const flagsTable = page.getByRole('region', { name: 'Your flags' }); + const flagSwitch = flagsTable.getByRole('switch'); await flagSwitch.waitFor({ state: 'visible' }); const wasChecked = (await flagSwitch.getAttribute('class'))?.includes( 'switch-checked', @@ -80,11 +77,8 @@ test.describe('Onboarding', () => { ); // The Onboarding badge (attached in bootstrap) shows in the flags table. - // Scope + exact: the header crumb also contains the word "Onboarding". - const flagsTable = page.locator('.onboarding-flags'); - await expect( - flagsTable.getByText('Onboarding', { exact: true }), - ).toBeVisible(); + // Exact match: the header crumb also contains the word "Onboarding". + await expect(flagsTable.getByText('Onboarding', { exact: true })).toBeVisible(); // Rename the flag. Names are immutable, so this delete + recreates; the // Onboarding tag must survive (the recreate carries the old flag's tags). @@ -96,10 +90,12 @@ test.describe('Onboarding', () => { // Reload to prove the rename persisted server-side and the tag came with it // (bootstrap is idempotent and reuses the renamed flag on revisit). await page.reload(); - await waitForElementVisible(byId('onboarding-flow'), 30000); + await flowReady(); await expect(page.getByLabel('Flag name')).toHaveValue('renamed_demo_flag'); await expect( - page.locator('.onboarding-flags').getByText('Onboarding', { exact: true }), + page + .getByRole('region', { name: 'Your flags' }) + .getByText('Onboarding', { exact: true }), ).toBeVisible(); await visualSnapshot(page, 'onboarding-renamed', testInfo); @@ -107,9 +103,7 @@ test.describe('Onboarding', () => { // the badge to LIVE so the connected UI is exercised end to end. log('Connected state'); await page.goto('/getting-started?connected'); - await waitForElementVisible(byId('onboarding-flow'), 30000); - await expect(page.locator(byId('onboarding-terminal-badge'))).toHaveText( - /LIVE/, - ); + await flowReady(); + await expect(page.getByText('LIVE', { exact: true })).toBeVisible(); }); });