Skip to content
Merged
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
126 changes: 126 additions & 0 deletions e2e/account-pickers.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { expect, test } from './playwright-utils.ts'
import {
seedIntegrationInE2eDatabase,
seedSavedPackageInE2eDatabase,
} from './d1-utils.ts'

test('account package picker and integration search use recognizable names', async ({
page,
seedE2eUser,
login,
}) => {
const runId = Date.now()
const user = await seedE2eUser({
email: `account-pickers-${runId}@example.com`,
username: `account-pickers-${runId}`,
password: 'account-pickers-password',
})
const calendarPackage = {
packageId: `50cfcf4d-83a0-48f0-b713-${String(runId).slice(-12)}`,
kodyId: `calendar-assistant-${runId}`,
name: `@account-pickers/calendar-${runId}`,
}
const tasksPackage = {
packageId: `8b3f33e5-353a-47b9-868e-${String(runId + 1).slice(-12)}`,
kodyId: `task-assistant-${runId}`,
name: `@account-pickers/tasks-${runId}`,
}

await seedSavedPackageInE2eDatabase({
ownerEmail: user.email,
...calendarPackage,
})
await seedSavedPackageInE2eDatabase({
ownerEmail: user.email,
...tasksPackage,
})
await seedIntegrationInE2eDatabase({
ownerEmail: user.email,
name: `github-${runId}`,
tokenUrl: 'https://github.com/login/oauth/access_token',
apiBaseUrl: 'https://api.github.com',
requiredHosts: ['api.github.com'],
scopes: ['repo', 'read:user'],
})
await seedIntegrationInE2eDatabase({
ownerEmail: user.email,
name: `spotify-${runId}`,
tokenUrl: 'https://accounts.spotify.com/api/token',
apiBaseUrl: 'https://api.spotify.com/v1',
requiredHosts: ['api.spotify.com'],
scopes: ['user-read-playback-state'],
})
await login({ ...user, mode: 'login' })

const secretName = `pickerSecret${runId}`
const createSecretResponse = await page.request.post(
'/account/secrets.json',
{
data: {
action: 'save',
currentId: null,
name: secretName,
scope: 'user',
packageId: null,
description: 'Secret used to exercise package access pickers.',
value: 'local-e2e-secret-value',
allowedHosts: [],
allowedCapabilities: [],
allowedPackages: [],
},
},
)
expect(createSecretResponse.ok()).toBe(true)

await page.goto(
`/account/secrets/user/${secretName}?package_id=${calendarPackage.packageId}&package=${calendarPackage.kodyId}`,
)
await expect(
page.getByRole('heading', { name: 'Approve secret access' }),
).toBeVisible()
const approvalCard = page
.getByRole('heading', { name: 'Approve secret access' })
.locator('..')
await expect(
approvalCard.getByText(calendarPackage.kodyId, { exact: true }),
).toBeVisible()
await expect(
approvalCard.getByText(calendarPackage.packageId, { exact: true }),
).toBeVisible()
await page.getByRole('button', { name: 'Approve', exact: true }).click()
await expect(
page.getByRole('button', {
name: `Remove package ${calendarPackage.kodyId}`,
}),
).toBeVisible()

const allowedPackagePicker = page.getByLabel('Add allowed package')
await allowedPackagePicker.fill(tasksPackage.kodyId)
await allowedPackagePicker.press('ArrowDown')
await allowedPackagePicker.press('Enter')
const selectedPackageRow = page
.getByRole('button', {
name: `Remove package ${tasksPackage.kodyId}`,
})
.locator('..')
await expect(
selectedPackageRow.getByText(tasksPackage.kodyId, { exact: true }),
).toBeVisible()
await expect(
selectedPackageRow.getByText(tasksPackage.packageId, { exact: true }),
).toBeVisible()
await page.getByRole('button', { name: 'Save', exact: true }).click()
await expect(page.getByText('Saved secret.')).toBeVisible()

await page.goto('/account/integrations')
const integrationSearch = page.getByLabel('Search integrations')
await expect(integrationSearch).toBeVisible()
await integrationSearch.fill('spotify playback')
await expect(
page.getByRole('heading', { name: `spotify-${runId}`, exact: true }),
).toBeVisible()
await expect(
page.getByRole('heading', { name: `github-${runId}`, exact: true }),
).not.toBeVisible()
await expect(page.getByText('1 of 2 integrations')).toBeVisible()
})
77 changes: 77 additions & 0 deletions e2e/d1-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,83 @@ export async function seedUserInE2eDatabase(input: {
)
}

export async function seedSavedPackageInE2eDatabase(input: {
ownerEmail: string
packageId: string
kodyId: string
name: string
}) {
const ownerUserId = await createStableUserIdFromEmail(input.ownerEmail)
const sourceId = `source-${input.packageId}`
executeE2eD1Command(
`INSERT INTO saved_packages (
id, user_id, name, kody_id, description, tags_json, source_id, has_app
) VALUES (
${quoteSqlString(input.packageId)},
${quoteSqlString(ownerUserId)},
${quoteSqlString(input.name)},
${quoteSqlString(input.kodyId)},
'Package seeded for account picker e2e coverage.',
'[]',
${quoteSqlString(sourceId)},
0
)
ON CONFLICT(id) DO UPDATE SET
name = excluded.name,
kody_id = excluded.kody_id,
updated_at = CURRENT_TIMESTAMP;`,
)
}

export async function seedIntegrationInE2eDatabase(input: {
ownerEmail: string
name: string
tokenUrl: string
apiBaseUrl: string
requiredHosts: Array<string>
scopes: Array<string>
}) {
const ownerUserId = await createStableUserIdFromEmail(input.ownerEmail)
const bucketId = `e2e-integration-bucket-${ownerUserId}`
const valueName = `_integration:${input.name}`
const value = JSON.stringify({
name: input.name,
tokenUrl: input.tokenUrl,
apiBaseUrl: input.apiBaseUrl,
flow: 'pkce',
clientIdValueName: `${input.name}-client-id`,
clientSecretSecretName: null,
accessTokenSecretName: `${input.name}-access-token`,
refreshTokenSecretName: `${input.name}-refresh-token`,
requiredHosts: input.requiredHosts,
authorization: {
authorizeUrl: `https://auth.${input.name}.example.com/authorize`,
scopes: input.scopes,
},
})
executeE2eD1Command(
`INSERT INTO value_buckets (id, user_id, scope, binding_key)
VALUES (
${quoteSqlString(bucketId)},
${quoteSqlString(ownerUserId)},
'user',
''
)
ON CONFLICT(user_id, scope, binding_key) DO UPDATE SET
updated_at = CURRENT_TIMESTAMP;
INSERT INTO value_entries (bucket_id, name, description, value)
VALUES (
${quoteSqlString(bucketId)},
${quoteSqlString(valueName)},
${quoteSqlString(`OAuth integration config for ${input.name}`)},
${quoteSqlString(value)}
)
ON CONFLICT(bucket_id, name) DO UPDATE SET
value = excluded.value,
updated_at = CURRENT_TIMESTAMP;`,
)
}

export function assignRoleInE2eDatabase(email: string, role: string) {
executeE2eD1Command(buildRoleAssignmentSql({ email, role }))
}
Expand Down
118 changes: 118 additions & 0 deletions packages/worker/client/combobox.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { type Handle, css } from 'remix/ui'
import {
Combobox as RemixCombobox,
ComboboxOption as RemixComboboxOption,
} from 'remix/ui/combobox'
import { onComboboxChange } from 'remix/ui/combobox/primitives'
import { colors, radius, shadows, spacing } from '#client/styles/tokens.ts'
import { fieldLabelCss, inputCss } from '#client/styles/style-primitives.ts'

export type ComboboxOption = {
id: string
label: string
description?: string | null
}

type ComboboxProps = {
id: string
label: string
placeholder?: string
value: string
options: Array<ComboboxOption>
disabled?: boolean
onChange: (value: string) => void
}

/**
* Kody's labeled, design-system adapter for Remix's accessible combobox.
* Remix owns filtering, draft and committed values, keyboard interaction,
* focus timing, popover behavior, and ARIA state.
*/
export function Combobox(handle: Handle<ComboboxProps>) {
return () => {
const props = handle.props
return (
<div mix={css(comboboxFieldCss)}>
<label for={props.id} mix={css(fieldLabelCss)}>
{props.label}
</label>
<RemixCombobox
inputId={props.id}
defaultValue={props.value || null}
disabled={props.disabled}
placeholder={props.placeholder}
mix={[
css(comboboxRootCss),
onComboboxChange((event) => {
if (event.value == null) return
props.onChange(event.value)
}),
]}
>
{props.options.map((option) => (
<RemixComboboxOption
key={option.id}
label={option.label}
searchValue={[option.label, option.description ?? '', option.id]}
value={option.id}
>
<span mix={css(comboboxOptionContentCss)}>
<span>{option.label}</span>
{option.description ? (
<span mix={css(comboboxDescriptionCss)}>
{option.description}
</span>
) : null}
</span>
</RemixComboboxOption>
))}
</RemixCombobox>
</div>
)
}
}

const comboboxFieldCss = {
display: 'grid',
gap: spacing.xs,
minWidth: 0,
}

const comboboxRootCss = {
position: 'relative' as const,
'& input[role="combobox"]': {
...inputCss,
minHeight: 'auto',
boxShadow: 'none',
},
'& [popover]': {
zIndex: 10,
maxHeight: '18rem',
overflowY: 'auto' as const,
borderRadius: radius.md,
border: `1px solid ${colors.border}`,
backgroundColor: colors.surface,
boxShadow: shadows.md,
padding: spacing.xs,
color: colors.text,
},
'& [role="option"]': {
borderRadius: radius.md,
color: colors.text,
'&[data-highlighted="true"], &:hover': {
backgroundColor: colors.primarySoftest,
},
},
}

const comboboxOptionContentCss = {
display: 'grid',
gap: spacing.xs,
minWidth: 0,
}

const comboboxDescriptionCss = {
fontSize: 'var(--font-size-sm)',
color: colors.textMuted,
overflowWrap: 'anywhere' as const,
}
Loading
Loading