Skip to content

Commit dda8221

Browse files
Bill Leoutsakoscursoragent
authored andcommitted
Add settings navigation acceptance contracts
Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent fc8a7c2 commit dda8221

16 files changed

Lines changed: 1888 additions & 11 deletions

File tree

apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.tsx

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,11 @@ export function SettingsSidebar({
5757
const showDiscardDialog = pendingLeave !== null
5858

5959
const [hasOverflowTop, setHasOverflowTop] = useState(false)
60+
const [isHydrated, setIsHydrated] = useState(false)
61+
62+
useEffect(() => {
63+
setIsHydrated(true)
64+
}, [])
6065

6166
const { data: session } = useSession()
6267
const hostContext = useWorkspaceHostContext()
@@ -265,7 +270,12 @@ export function SettingsSidebar({
265270
)}
266271
>
267272
<SidebarTooltip label='Back' enabled={showCollapsedTooltips}>
268-
<button type='button' onClick={handleBack} className={chipVariants({ fullWidth: true })}>
273+
<button
274+
type='button'
275+
disabled={!isHydrated}
276+
onClick={handleBack}
277+
className={chipVariants({ fullWidth: true })}
278+
>
269279
<div className='flex size-[16px] flex-shrink-0 items-center justify-center text-[var(--text-icon)]'>
270280
<ChevronDown className='size-[10px] rotate-90' />
271281
</div>
@@ -276,6 +286,8 @@ export function SettingsSidebar({
276286

277287
{/* Settings sections */}
278288
<div
289+
role='navigation'
290+
aria-label='Workspace settings sections'
279291
ref={isCollapsed ? undefined : scrollContainerRef}
280292
className={cn(
281293
'flex flex-1 flex-col overflow-y-auto overflow-x-hidden border-t pt-1.5 transition-colors duration-150',
@@ -337,6 +349,9 @@ export function SettingsSidebar({
337349
) : (
338350
<button
339351
type='button'
352+
disabled={!isHydrated}
353+
aria-label={item.label}
354+
aria-current={active ? 'page' : undefined}
340355
className={itemClassName}
341356
onMouseEnter={() => handlePrefetch(item.id)}
342357
onFocus={() => handlePrefetch(item.id)}

apps/sim/components/settings/settings-sidebar.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,11 @@ export function SettingsSidebar<Section extends SettingsSection>({
6363
const cancelLeave = useSettingsDirtyStore((state) => state.cancelLeave)
6464
const pendingLeave = useSettingsDirtyStore((state) => state.pendingLeave)
6565
const [hasOverflowTop, setHasOverflowTop] = useState(false)
66+
const [isHydrated, setIsHydrated] = useState(false)
67+
68+
useEffect(() => {
69+
setIsHydrated(true)
70+
}, [])
6671

6772
useEffect(() => {
6873
const container = scrollContainerRef.current
@@ -85,6 +90,7 @@ export function SettingsSidebar<Section extends SettingsSection>({
8590
<SidebarTooltip label='Back' enabled={showCollapsedTooltips}>
8691
<button
8792
type='button'
93+
disabled={!isHydrated}
8894
onClick={() => requestLeave(() => router.push(backHref))}
8995
className={chipVariants({ fullWidth: true })}
9096
>
@@ -130,6 +136,9 @@ export function SettingsSidebar<Section extends SettingsSection>({
130136
>
131137
<button
132138
type='button'
139+
disabled={!isHydrated}
140+
aria-label={item.label}
141+
aria-current={active ? 'page' : undefined}
133142
className={chipVariants({ active, fullWidth: true })}
134143
onClick={() => {
135144
if (active) return

apps/sim/e2e/README.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,38 @@ new database, Stripe fake, app, realtime process, and browser run:
9999
bun run test:e2e -- --reuse-build --project=hosted-billing-chromium-navigation
100100
```
101101

102+
## Settings navigation contracts
103+
104+
Step 3 owns three literal acceptance datasets in
105+
`e2e/settings/navigation/contracts.ts`: canonical sidebar sections, special
106+
route outcomes, and representative persona visibility. They are intentionally
107+
independent of production navigation metadata. When product copy, routes, or
108+
visibility change, update the product and these expectations together rather
109+
than generating expectations from the implementation.
110+
111+
Run only the navigation contracts during local iteration:
112+
113+
```bash
114+
bun run test:e2e -- --reuse-build \
115+
--project=hosted-billing-chromium-navigation --no-deps \
116+
e2e/settings/navigation
117+
```
118+
119+
The complete navigation project includes foundation safety and unauthenticated
120+
smoke coverage. On the Step 3 reference run, one worker completed its 123 tests
121+
in 1.7 minutes and the cache-hit orchestrator in 5 minutes 10 seconds. Two
122+
workers completed the same retry-free project in 55.3 seconds and the
123+
cache-hit orchestrator in 4 minutes 25 seconds, so the project retains two
124+
workers. Foundation coverage passed in both measurements. The final
125+
post-review dependency chain passed all 139 navigation, workflow, persona, and
126+
isolation tests in 1.3 minutes of Playwright time.
127+
128+
The full chain's isolated browser contexts share a loopback address, so the E2E
129+
app raises Better Auth's generic request ceiling without disabling its limiter.
130+
That override fails closed unless the exact hosted E2E profile, E2E auth origin,
131+
and loopback `sim_e2e_*` database are all present; normal deployments retain
132+
Better Auth's defaults.
133+
102134
The cache lives under ignored `e2e/.cache/builds/`. A hit requires matching
103135
source contents (including uncommitted/untracked files), build/public profile,
104136
Node/Bun/Next versions, platform, `BUILD_ID`, and the cached artifact checksum.
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import type { Page } from '@playwright/test'
2+
import { expect, test } from '../../fixtures/persona-test'
3+
import { absoluteE2eUrl, readinessLocator, resolveContractPath } from './contract-resolver'
4+
import { type SectionContract, sectionContracts } from './contracts'
5+
6+
for (const literalContract of sectionContracts) {
7+
const contract: SectionContract = literalContract
8+
test(`${contract.contractId} is reachable through its sidebar contract`, async ({
9+
contextForPersona,
10+
personaManifest,
11+
}) => {
12+
const context = await contextForPersona(contract.driver.personaKey)
13+
const page = await context.newPage()
14+
const targetPath = resolveContractPath(personaManifest, contract.pathTemplate, contract.driver)
15+
const startingPath = resolveStartingPath(contract, personaManifest)
16+
17+
await page.goto(absoluteE2eUrl(startingPath))
18+
19+
const targetButton = page.getByRole('button', { name: contract.label, exact: true })
20+
await expect(targetButton).toBeVisible()
21+
22+
const successfulResponse = contract.successfulResponse
23+
? page.waitForResponse((response) => {
24+
const url = new URL(response.url())
25+
return url.pathname === contract.successfulResponse?.path && response.status() === 200
26+
})
27+
: null
28+
await targetButton.click()
29+
await expect(page).toHaveURL(absoluteE2eUrl(targetPath))
30+
if (successfulResponse) {
31+
const response = await successfulResponse
32+
if (contract.successfulResponse?.expectedJson !== undefined) {
33+
await expect(response.json()).resolves.toEqual(contract.successfulResponse.expectedJson)
34+
}
35+
}
36+
37+
await assertCanonicalSettingsPage(page, contract, targetPath)
38+
})
39+
}
40+
41+
async function assertCanonicalSettingsPage(
42+
page: Page,
43+
contract: SectionContract,
44+
targetPath: string
45+
): Promise<void> {
46+
await expect(page).toHaveURL(absoluteE2eUrl(targetPath))
47+
await expect(
48+
page.getByRole('heading', { name: contract.heading, level: 1, exact: true }),
49+
`${contract.contractId} heading changed; update the literal navigation contract intentionally`
50+
).toBeVisible()
51+
await expect(
52+
page.getByText(contract.description, { exact: true }),
53+
`${contract.contractId} description changed; update the literal navigation contract intentionally`
54+
).toBeVisible()
55+
await expect(readinessLocator(page, contract.readiness)).toBeVisible()
56+
await expect(page.getByRole('button', { name: contract.label, exact: true })).toHaveAttribute(
57+
'aria-current',
58+
'page'
59+
)
60+
await expect(
61+
page.getByRole('heading', { name: 'Failed to load settings', exact: true })
62+
).toHaveCount(0)
63+
await expect(
64+
page.getByRole('heading', { name: 'Something went wrong', exact: true })
65+
).toHaveCount(0)
66+
await expect(
67+
page.getByRole('heading', { name: 'Settings unavailable', exact: true })
68+
).toHaveCount(0)
69+
await expect(page.getByRole('heading', { name: 'Setting unavailable', exact: true })).toHaveCount(
70+
0
71+
)
72+
}
73+
74+
function resolveStartingPath(
75+
contract: SectionContract,
76+
manifest: Parameters<typeof resolveContractPath>[0]
77+
): string {
78+
if (contract.plane === 'account') {
79+
return contract.sectionId === 'general'
80+
? '/account/settings/api-keys'
81+
: '/account/settings/general'
82+
}
83+
84+
if (contract.plane === 'organization') {
85+
const section = contract.sectionId === 'members' ? 'access-control' : 'members'
86+
return resolveContractPath(
87+
manifest,
88+
`/organization/{organizationId}/settings/${section}`,
89+
contract.driver
90+
)
91+
}
92+
93+
const section = contract.sectionId === 'general' ? 'secrets' : 'general'
94+
return resolveContractPath(
95+
manifest,
96+
`/workspace/{workspaceId}/settings/${section}`,
97+
contract.driver
98+
)
99+
}

0 commit comments

Comments
 (0)