feat(studio): CLI deploy instructions for workers (FE-4191) (#49194)

## What

The surface that shows you how to deploy a worker from the CLI, plus the
product rename and the alpha framing.

- **Compute → Workers.** `PRODUCT_NAME` and `CLI_NAME` now say `Workers`
/ `workers`, so the sidebar, page title, command menu, and every
generated snippet match the CLI. One name, not two.
- **`DeployWorkerDialog`** — scaffold / configure / push, with copyable
`supabase workers` and `config.toml` snippets
- **`WorkersEmptyState`** — an `EmptyStatePresentational` with a
permission-gated deploy action
- **`AlphaNotice`** on the list page, and a **New** badge on the sidebar
entry (`Route.isNew`)
- **`WorkerSnippetTabs`** — CLI, `config.toml`, and curl/JS/Python calls
built from one worker shape. Reused by #49195.

Snippet URLs resolve from the project's `app_config.endpoint`
(`https://<project>/workers/v1/<name>`, the same shape as
`/functions/v1/`), and fall back to `[YOUR WORKER URL]` before settings
load rather than printing a wrong host.

## How to test

Only on the **Mockamaster** project in staging — it is the one project
in the alpha allow-list.

1. Staging dashboard → Mockamaster → **Workers** (the sidebar entry
carries a **New** badge)
2. **Deploy a worker** → step through the tabs; every snippet should
name the real worker URL, not a placeholder
3. Copy the cURL snippet and run it: expect `401`. Reaching a deployed
worker needs a second allow-list (`WORKERS_ALLOWED_PROJECTS` in
api-gateway `customer-router/wrangler.toml`), separate from the flag
that unlocks the dashboard.
4. Open a project with no workers to see the empty state

## Tests

`workerSnippets.test.ts` covers the generated output users copy: worker
URL in all three call snippets, the `[YOUR WORKER URL]` fallback, anon
vs service-role placeholder, empty-name fallback and trimming, runtime
default, and every `config.toml` field.

## Unverified copy

The dialog steps and the `supabase workers <sub>` subcommands come from
the original POC spec, not from the shipped CLI. Same for "Dockerfile,
Node.js and Deno supported" in the empty state — only Deno is confirmed
end to end. Worth a check by someone who knows the CLI surface.

Closes FE-4191

---------

Co-authored-by: Francesco Sansalvadore <f.sansalvadore@gmail.com>
This commit is contained in:
Jordi EnricandFrancesco Sansalvadore authored and GitHub committed 2026-08-25 15:38:57 +02:00
1 parent b108c4065c
commit 509d80c9eb
15 files changed
+458 -22

No files matched your search

@@ -7,6 +7,7 @@ import Link from 'next/link'
import { useRouter } from 'next/router'
import { ComponentProps, ComponentPropsWithoutRef, FC, ReactNode, useEffect } from 'react'
import {
Badge,
Button,
cn,
DropdownMenu,
@@ -195,6 +196,14 @@ export function SideBarNavLink({
<>
{route.icon}
<span>{route.label}</span>
{route.isNew && (
<Badge
variant="success"
className="ml-auto shrink-0 px-1.5 py-0.5 text-[9px] uppercase tracking-wider"
>
New
</Badge>
)}
</>
)
@@ -0,0 +1,67 @@
import {
Dialog,
DialogContent,
DialogHeader,
DialogSection,
DialogSectionSeparator,
DialogTitle,
} from 'ui'
import { EXAMPLE_WORKER } from './workerSnippets'
import { WorkerSnippetTabs } from './WorkerSnippetTabs'
interface DeployWorkerDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
}
const STEPS = [
{
title: 'Scaffold the worker',
description: 'Creates supabase/workers/<name>/ with an entrypoint for the runtime you pick.',
},
{
title: 'Configure it',
description: 'Runtime, size, access, and instance count live in supabase/config.toml.',
},
{
title: 'Push it',
description: 'Builds the image and schedules it on a microVM in US West.',
},
]
export const DeployWorkerDialog = ({ open, onOpenChange }: DeployWorkerDialogProps) => (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent size="large">
<DialogHeader>
<DialogTitle>Deploy a worker</DialogTitle>
</DialogHeader>
<DialogSection className="space-y-4">
<p className="text-sm text-foreground-light">
Workers are deployed with the Supabase CLI. This dashboard is read-only during the private
alpha.
</p>
<ol className="space-y-4">
{STEPS.map((step, index) => (
<li key={step.title} className="flex gap-3">
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full border border-strong text-xs text-foreground-light">
{index + 1}
</span>
<div className="space-y-0.5">
<p className="text-sm text-foreground">{step.title}</p>
<p className="text-sm text-foreground-lighter">{step.description}</p>
</div>
</li>
))}
</ol>
</DialogSection>
<DialogSectionSeparator />
<DialogSection>
<WorkerSnippetTabs input={EXAMPLE_WORKER} tabs={['cli', 'config']} />
</DialogSection>
</DialogContent>
</Dialog>
)
@@ -0,0 +1,23 @@
import CopyButton from '@/components/ui/CopyButton'
interface WorkerCommandLineProps {
command: string
comment?: string
}
export const WorkerCommandLine = ({ command, comment }: WorkerCommandLineProps) => (
<div className="space-y-1">
{comment && <p className="font-mono text-xs text-foreground-lighter">{`> ${comment}`}</p>}
<div className="flex items-center gap-2 font-mono text-sm text-foreground">
<span className="text-foreground-lighter">$</span>
<span className="flex-1">{command}</span>
<CopyButton
text={command}
iconOnly
variant="text"
aria-label="Copy command"
className="text-foreground-lighter hover:text-foreground"
/>
</div>
</div>
)
@@ -0,0 +1,84 @@
import { useParams } from 'common'
import { useState } from 'react'
import { cn } from 'ui'
import { buildWorkerSnippets, type WorkerSnippetInput } from './workerSnippets'
import CopyButton from '@/components/ui/CopyButton'
import { useProjectSettingsV2Query } from '@/data/config/project-settings-v2-query'
export type WorkerSnippetTab = 'config' | 'cli' | 'curl' | 'js' | 'python'
const TAB_LABEL: Record<WorkerSnippetTab, string> = {
config: 'config.toml',
cli: 'CLI',
curl: 'cURL',
js: 'JavaScript',
python: 'Python',
}
interface WorkerSnippetTabsProps {
input: Omit<WorkerSnippetInput, 'endpoint' | 'protocol'>
tabs?: [WorkerSnippetTab, ...WorkerSnippetTab[]]
className?: string
}
export const WorkerSnippetTabs = ({
input,
tabs = ['cli', 'curl'],
className,
}: WorkerSnippetTabsProps) => {
const { ref } = useParams()
const [active, setActive] = useState<WorkerSnippetTab>(tabs[0])
const { data: settings } = useProjectSettingsV2Query({ projectRef: ref }, { enabled: !!ref })
const snippets = buildWorkerSnippets({
...input,
endpoint: settings?.app_config?.endpoint,
protocol: settings?.app_config?.protocol,
})
const snippetByTab: Record<WorkerSnippetTab, string> = {
config: snippets.configToml,
cli: snippets.cli,
curl: snippets.curl,
js: snippets.javascript,
python: snippets.python,
}
const activeTab = tabs.includes(active) ? active : tabs[0]
const value = snippetByTab[activeTab]
return (
<div className={cn('flex flex-col gap-y-2', className)}>
<div className="flex items-center gap-x-4 border-b border-default">
{tabs.map((tab) => (
<button
key={tab}
type="button"
tabIndex={0}
onClick={() => setActive(tab)}
className={cn(
'-mb-px border-b px-0.5 py-1.5 text-sm transition-colors',
tab === activeTab
? 'border-foreground text-foreground'
: 'border-transparent text-foreground-lighter hover:text-foreground-light'
)}
>
{TAB_LABEL[tab]}
</button>
))}
</div>
<div className="relative rounded-md border border-default bg-surface-100">
<CopyButton
text={value}
iconOnly
variant="text"
aria-label="Copy snippet"
className="absolute right-2 top-2 text-foreground-lighter hover:text-foreground"
/>
<pre className="overflow-x-auto p-3 pr-10 font-mono text-xs leading-relaxed text-foreground-light">
{value}
</pre>
</div>
</div>
)
}
@@ -0,0 +1,41 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { BoxPlus } from 'icons'
import { Plus } from 'lucide-react'
import { EmptyStatePresentational } from 'ui-patterns/EmptyStatePresentational'
import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
interface WorkersEmptyStateProps {
onDeploy: () => void
}
export const WorkersEmptyState = ({ onDeploy }: WorkersEmptyStateProps) => {
const { can: canDeployWorkers } = useAsyncCheckPermissions(PermissionAction.FUNCTIONS_WRITE, '*')
return (
<EmptyStatePresentational
icon={BoxPlus}
title="Deploy your first worker"
description="Spin up and deploy a worker locally, then deploy it to the cloud. Dockerfile, Node.js and Deno supported at Private Alpha."
>
<ButtonTooltip
variant="primary"
size="tiny"
icon={<Plus size={14} />}
disabled={!canDeployWorkers}
onClick={onDeploy}
tooltip={{
content: {
side: 'bottom',
text: canDeployWorkers
? undefined
: 'You need additional permissions to deploy workers',
},
}}
>
Deploy a worker
</ButtonTooltip>
</EmptyStatePresentational>
)
}
@@ -1,6 +1,6 @@
import { screen, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Worker } from './Workers.types'
import { WorkersList } from './WorkersList'
@@ -19,7 +19,7 @@ const worker = (name: string, overrides: Partial<Worker> = {}): Worker => ({
})
const renderList = (workers: Worker[]) =>
customRender(<WorkersList projectRef="default" workers={workers} />)
customRender(<WorkersList projectRef="default" workers={workers} onDeploy={vi.fn()} />)
const rowNames = () =>
screen
@@ -1,9 +1,10 @@
import { ChevronLeft, ChevronRight } from 'lucide-react'
import { ChevronLeft, ChevronRight, Terminal } from 'lucide-react'
import Link from 'next/link'
import { useRouter } from 'next/router'
import { useState } from 'react'
import {
Badge,
Button,
Card,
CardFooter,
Input,
@@ -30,6 +31,7 @@ import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
interface WorkersListProps {
projectRef: string
workers: Worker[]
onDeploy: () => void
}
const STATE_FILTERS: { value: WorkerBuildState | 'all'; label: string }[] = [
@@ -53,7 +55,7 @@ const parseStateFilter = (value: string): WorkerBuildState | 'all' =>
const parseAccessFilter = (value: string): WorkerAccess | 'all' =>
ACCESS_FILTERS.find((option) => option.value === value)?.value ?? 'all'
export const WorkersList = ({ projectRef, workers }: WorkersListProps) => {
export const WorkersList = ({ projectRef, workers, onDeploy }: WorkersListProps) => {
const router = useRouter()
const [search, setSearch] = useState('')
const [stateFilter, setStateFilter] = useState<WorkerBuildState | 'all'>('all')
@@ -125,6 +127,9 @@ export const WorkersList = ({ projectRef, workers }: WorkersListProps) => {
<span className="text-sm text-foreground-lighter">
{filtered.length} worker{filtered.length === 1 ? '' : 's'}
</span>
<Button variant="primary" icon={<Terminal />} onClick={onDeploy}>
Deploy a worker
</Button>
</div>
</div>
@@ -0,0 +1,81 @@
import { describe, expect, it } from 'vitest'
import { WORKERS_REGION } from './Workers.constants'
import { buildWorkerCliCommands, buildWorkerSnippets, EXAMPLE_WORKER } from './workerSnippets'
const input = (overrides: Partial<Parameters<typeof buildWorkerSnippets>[0]> = {}) => ({
...EXAMPLE_WORKER,
endpoint: 'abcdefgh.supabase.co',
...overrides,
})
describe('buildWorkerSnippets', () => {
it('points every call snippet at the worker URL', () => {
const { curl, javascript, python } = buildWorkerSnippets(input({ name: 'embed' }))
const url = 'https://abcdefgh.supabase.co/workers/v1/embed'
expect(curl).toContain(`'${url}'`)
expect(javascript).toContain(`'${url}'`)
expect(python).toContain(`"${url}"`)
})
it('leaves a placeholder URL until the project settings resolve', () => {
const { curl } = buildWorkerSnippets(input({ endpoint: undefined }))
expect(curl).toContain('[YOUR WORKER URL]')
})
it('asks for the anon key on a public worker and the service role key on a private one', () => {
expect(buildWorkerSnippets(input({ access: 'public' })).curl).toContain('[YOUR ANON KEY]')
expect(buildWorkerSnippets(input({ access: 'private' })).curl).toContain(
'[YOUR SERVICE ROLE KEY]'
)
expect(buildWorkerSnippets(input({ access: 'private' })).javascript).toContain(
'[YOUR SERVICE ROLE KEY]'
)
})
it('falls back to a placeholder name when the worker has none', () => {
expect(buildWorkerSnippets(input({ name: ' ' })).cli).toContain('my-worker')
})
it('trims the name before interpolating it', () => {
expect(buildWorkerSnippets(input({ name: ' embed ' })).cli).toContain('new embed --runtime')
})
it('defaults the runtime when the API omits it', () => {
expect(buildWorkerSnippets(input({ runtime: undefined })).cli).toContain('--runtime node')
})
it('writes the worker spec into the config.toml block', () => {
const { configToml } = buildWorkerSnippets(
input({
name: 'embed',
runtime: 'python',
size: '4gb-2vcpu',
access: 'private',
instances: 3,
})
)
expect(configToml).toContain('[workers.embed]')
expect(configToml).toContain('runtime = "python"')
expect(configToml).toContain('size = "4gb-2vcpu" # 4 GB · 2 vCPU')
expect(configToml).toContain('access = "private"')
expect(configToml).toContain('instances = 3')
expect(configToml).toContain(WORKERS_REGION)
})
})
describe('buildWorkerCliCommands', () => {
it('names the worker in every command', () => {
const commands = buildWorkerCliCommands('embed')
expect(commands).not.toHaveLength(0)
for (const { command } of commands) {
expect(command).toContain('embed')
}
})
it('falls back to a placeholder name when the worker has none', () => {
expect(buildWorkerCliCommands(' ')[0].command).toContain('my-worker')
})
})
@@ -0,0 +1,111 @@
import { WORKERS_REGION, workerUrl } from './Workers.constants'
import type { WorkerAccess } from './Workers.types'
import { formatSize } from './Workers.utils'
import { CLI_NAME } from '@/lib/constants/workers'
export interface WorkerSnippetInput {
name: string
endpoint: string | undefined
protocol?: string
runtime: string | undefined
size: string
access: WorkerAccess
instances: number
}
export interface WorkerSnippets {
configToml: string
cli: string
curl: string
javascript: string
python: string
}
export const EXAMPLE_WORKER: Omit<WorkerSnippetInput, 'endpoint' | 'protocol'> = {
name: 'my-worker',
runtime: 'node',
size: '2gb-1vcpu',
access: 'public',
instances: 1,
}
const safeName = (name: string) => (name.trim().length > 0 ? name.trim() : 'my-worker')
export function buildWorkerSnippets(input: WorkerSnippetInput): WorkerSnippets {
const name = safeName(input.name)
const runtime = input.runtime ?? 'node'
const url =
workerUrl({ endpoint: input.endpoint, protocol: input.protocol, name }) ?? '[YOUR WORKER URL]'
const cli = [
`supabase ${CLI_NAME} new ${name} --runtime ${runtime}`,
`supabase ${CLI_NAME} push ${name}`,
].join('\n')
const configToml = [
`# supabase/config.toml`,
``,
`[${CLI_NAME}.${name}]`,
`runtime = "${runtime}"`,
`size = "${input.size}" # ${formatSize(input.size)}`,
`access = "${input.access}"`,
`instances = ${input.instances}`,
`# region is locked to ${WORKERS_REGION} at alpha`,
].join('\n')
const authHeader =
input.access === 'public'
? ` -H 'Authorization: Bearer [YOUR ANON KEY]' \\`
: ` -H 'Authorization: Bearer [YOUR SERVICE ROLE KEY]' \\`
const curl = [
`curl -L -X POST '${url}' \\`,
authHeader,
` -H 'Content-Type: application/json' \\`,
` --data '{"name":"world"}'`,
].join('\n')
const keyPlaceholder = input.access === 'public' ? '[YOUR ANON KEY]' : '[YOUR SERVICE ROLE KEY]'
const javascript = [
`const res = await fetch('${url}', {`,
` method: 'POST',`,
` headers: {`,
` Authorization: 'Bearer ${keyPlaceholder}',`,
` 'Content-Type': 'application/json',`,
` },`,
` body: JSON.stringify({ name: 'world' }),`,
`})`,
`const data = await res.json()`,
].join('\n')
const python = [
`import requests`,
``,
`res = requests.post(`,
` "${url}",`,
` headers={"Authorization": "Bearer ${keyPlaceholder}"},`,
` json={"name": "world"},`,
`)`,
`print(res.json())`,
].join('\n')
return { configToml, cli, curl, javascript, python }
}
export interface WorkerCliCommand {
comment: string
command: string
}
export function buildWorkerCliCommands(name: string): WorkerCliCommand[] {
const slug = safeName(name)
return [
{ comment: 'Recreate the source locally', command: `supabase ${CLI_NAME} pull ${slug}` },
{ comment: 'Run it locally on a port', command: `supabase ${CLI_NAME} serve ${slug}` },
{ comment: 'Deploy a new version', command: `supabase ${CLI_NAME} push ${slug}` },
{ comment: 'Stream logs', command: `supabase ${CLI_NAME} logs ${slug} --follow` },
{ comment: 'Delete the worker', command: `supabase ${CLI_NAME} delete ${slug}` },
]
}
@@ -148,6 +148,7 @@ export const generateProductRoutes = (
disabled: !isProjectActive,
icon: <Box size={ICON_SIZE} strokeWidth={ICON_STROKE_WIDTH} />,
link: ref && (isProjectBuilding ? buildingUrl : `/project/${ref}/workers`),
isNew: true,
},
]
: []),
@@ -85,7 +85,7 @@ const WorkersLayoutContent = ({ children, title }: PropsWithChildren<WorkersLayo
{canReadWorkers ? (
children
) : (
<NoPermission isFullPage resourceText={`view this project's ${PRODUCT_NAME} workers`} />
<NoPermission isFullPage resourceText="view this project's workers" />
)}
</ProjectLayout>
)
+1
View File
@@ -10,6 +10,7 @@ export interface Route {
disabled?: boolean
linkElement?: ReactNode
items?: any | Route[]
isNew?: boolean
/**
* Binds a registered keyboard shortcut to this route when set. The sidebar
* entry shows the keybind on hover and jumps to `link` when the shortcut
+4 -4
View File
@@ -1,5 +1,5 @@
export const PRODUCT_NAME = 'Compute'
export const CLI_NAME = 'compute'
export const PRODUCT_NAME = 'Workers'
export const CLI_NAME = 'workers'
export const WORKERS_CLI_DEPLOY = `supabase ${CLI_NAME} push`
@@ -14,7 +14,7 @@ Add a block to \`supabase/config.toml\`:
\`\`\`toml
[${CLI_NAME}.embed]
runtime = "python" # node | deno | bun | python | dockerfile
size = "2x1" # 2x1 (2GB/1vCPU) | 4x2 (4GB/2vCPU) — fixed at deploy
size = "2gb-1vcpu" # 2gb-1vcpu | 4gb-2vcpu — fixed at deploy
access = "public" # public | private
instances = 1 # 1..10 per deploy; 100 cap per project
secrets = ["OPENAI_API_KEY"] # names only — values live in the Secrets API
@@ -33,7 +33,7 @@ Config precedence: \`--flag\` > \`config.toml\` > interactive prompt > default.
- Workers under \`supabase/${CLI_NAME}/<name>/\` are auto-discovered; the folder name is the slug.
- \`entrypoint\` is inferred from the runtime (node index.js, deno run main.ts, python main.py, Dockerfile CMD).
- \`region\` is locked to us-west-1 at alpha — do not set it.
- \`region\` is locked to us-west-2 at alpha — do not set it.
- Sizes are fixed at deploy time. To change size, delete the worker and redeploy.
## Manage
@@ -1,5 +1,6 @@
import { useQuery } from '@tanstack/react-query'
import { useParams } from 'common'
import { useState } from 'react'
import { Admonition } from 'ui-patterns/Admonition'
import { PageContainer } from 'ui-patterns/PageContainer'
import {
@@ -12,14 +13,17 @@ import {
import { PageSection, PageSectionContent } from 'ui-patterns/PageSection'
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
import { DeployWorkerDialog } from '@/components/interfaces/Workers/DeployWorkerDialog'
import {
isWorkersForbidden,
isWorkersUnavailable,
} from '@/components/interfaces/Workers/Workers.utils'
import { WorkersEmptyState } from '@/components/interfaces/Workers/WorkersEmptyState'
import { WorkersList } from '@/components/interfaces/Workers/WorkersList'
import { DefaultLayout } from '@/components/layouts/DefaultLayout'
import { WorkersLayout } from '@/components/layouts/WorkersLayout/WorkersLayout'
import { AlertError } from '@/components/ui/AlertError'
import { AlphaNotice } from '@/components/ui/AlphaNotice'
import { NoPermission } from '@/components/ui/NoPermission'
import { workersQueryOptions } from '@/data/workers/workers-query'
import { PRODUCT_NAME } from '@/lib/constants/workers'
@@ -27,6 +31,7 @@ import type { NextPageWithLayout } from '@/types'
const WorkersPage: NextPageWithLayout = () => {
const { ref } = useParams()
const [isDeployInstructionsOpen, setIsDeployInstructionsOpen] = useState(false)
const {
data: workers,
error,
@@ -46,7 +51,7 @@ const WorkersPage: NextPageWithLayout = () => {
<PageHeaderSummary>
<PageHeaderTitle>{PRODUCT_NAME}</PageHeaderTitle>
<PageHeaderDescription>
Run backend workers in microVMs next to your database
Run fully managed compute in isolation next to your database
</PageHeaderDescription>
</PageHeaderSummary>
</PageHeaderMeta>
@@ -54,7 +59,12 @@ const WorkersPage: NextPageWithLayout = () => {
<PageContainer size="large">
<PageSection>
<PageSectionContent>
<PageSectionContent className="flex flex-col gap-y-8">
<AlphaNotice
entity="Workers"
feedbackUrl="https://github.com/orgs/supabase/discussions"
/>
{isPending && <GenericSkeletonLoader />}
{isNotEnrolled && (
<Admonition
@@ -63,21 +73,26 @@ const WorkersPage: NextPageWithLayout = () => {
description={`${PRODUCT_NAME} is in private alpha. Contact support to have this project added to the alpha.`}
/>
)}
{isMissingPermission && (
<NoPermission resourceText={`view this project's ${PRODUCT_NAME} workers`} />
)}
{isMissingPermission && <NoPermission resourceText="view this project's workers" />}
{isUnexpectedError && <AlertError error={error} subject="Failed to retrieve workers" />}
{isSuccess && workers.length === 0 && (
<p className="text-sm text-foreground-light">
No workers yet. Deploy your first worker with the Supabase CLI.
</p>
<WorkersEmptyState onDeploy={() => setIsDeployInstructionsOpen(true)} />
)}
{isSuccess && workers.length > 0 && ref && (
<WorkersList projectRef={ref} workers={workers} />
<WorkersList
projectRef={ref}
workers={workers}
onDeploy={() => setIsDeployInstructionsOpen(true)}
/>
)}
</PageSectionContent>
</PageSection>
</PageContainer>
<DeployWorkerDialog
open={isDeployInstructionsOpen}
onOpenChange={setIsDeployInstructionsOpen}
/>
</div>
)
}
@@ -86,7 +86,7 @@ describe('/project/[ref]/workers', () => {
await renderWorkersPage()
expect(screen.getByText(/No workers yet/)).toBeVisible()
expect(screen.getByText('Deploy your first worker')).toBeVisible()
expect(screen.queryByRole('table')).not.toBeInTheDocument()
})
@@ -105,9 +105,7 @@ describe('/project/[ref]/workers', () => {
await renderWorkersPage()
expect(
screen.getByText(
`You need additional permissions to view this project's ${PRODUCT_NAME} workers`
)
screen.getByText("You need additional permissions to view this project's workers")
).toBeVisible()
expect(screen.queryByRole('table')).not.toBeInTheDocument()
})