diff --git a/.github/workflows/docs-e2e.yml b/.github/workflows/docs-e2e.yml
index c608a8418af..289bb4aebeb 100644
--- a/.github/workflows/docs-e2e.yml
+++ b/.github/workflows/docs-e2e.yml
@@ -129,6 +129,10 @@ jobs:
# Vercel skips the docs preview when a PR only changes the harness
# (e2e/docs, workflow), so wait for a preview only when apps/docs changed.
+ # When apps/docs changed earlier in the PR but not in the head commit,
+ # Vercel skips the head build as not affected and its URL serves a
+ # placeholder page; the script then uses the newest READY preview from
+ # an earlier commit of the PR, which serves the same docs content.
#
# Vercel's GitHub App stopped writing GitHub Deployment objects on
# 2026-02-17 (broken app auth), so vercel/wait-for-deployment-action
@@ -146,6 +150,7 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
+ PR_NUMBER: ${{ github.event.pull_request.number }}
VERCEL_STATUS_CONTEXT: 'Vercel – docs'
VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
VERCEL_TEAM_ID: ${{ secrets.VERCEL_TEAM_ID }}
diff --git a/.github/workflows/www-e2e.yml b/.github/workflows/www-e2e.yml
index 7859ca0033c..2d52534798d 100644
--- a/.github/workflows/www-e2e.yml
+++ b/.github/workflows/www-e2e.yml
@@ -120,7 +120,10 @@ jobs:
cache: 'pnpm'
# Vercel skips the preview when only the harness changed, so wait for one
- # only when apps/www changed. See scripts/waitForVercelPreview.js.
+ # only when apps/www changed. A head commit that leaves apps/www untouched
+ # gets a skipped build whose URL serves a placeholder page, so the script
+ # falls back to the newest READY preview from an earlier commit of the PR.
+ # See scripts/waitForVercelPreview.js.
- name: Wait for Vercel www preview
if: steps.scope.outputs.skip == 'false' && github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && steps.changes.outputs.www_app == 'true'
id: deployment
@@ -129,6 +132,7 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
+ PR_NUMBER: ${{ github.event.pull_request.number }}
VERCEL_STATUS_CONTEXT: 'Vercel – zone-www-dot-com'
VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
VERCEL_TEAM_ID: ${{ secrets.VERCEL_TEAM_ID }}
diff --git a/apps/docs/content/guides/auth/server-side/advanced-guide.mdx b/apps/docs/content/guides/auth/server-side/advanced-guide.mdx
index 8366b9a7c99..5659375a70f 100644
--- a/apps/docs/content/guides/auth/server-side/advanced-guide.mdx
+++ b/apps/docs/content/guides/auth/server-side/advanced-guide.mdx
@@ -46,6 +46,8 @@ It is likely that the refresh token sent from the browser to your server is stal
When you receive this error on the server-side, try to defer rendering to the browser where the client library can access an up-to-date refresh token and present the user with a better experience.
+A common cause is calling `supabase.auth.signOut()` without a `scope`. It defaults to `scope: 'global'`, which revokes the refresh token for every session that user has. That includes other tabs, devices, and any SSR context still holding the old token. See [Sign out and scopes](/docs/guides/auth/signout#sign-out-and-scopes). Pass `{ scope: 'local' }` if you only want to end the current session.
+
### Should I set a shorter `Max-Age` parameter on the cookies?
The `Max-Age` or `Expires` cookie parameters only control whether the browser sends the value to the server. Since a refresh token represents the long-lived authentication session of the user on that browser, setting a short `Max-Age` or `Expires` parameter on the cookies only results in a degraded user experience.
diff --git a/apps/docs/content/guides/auth/signout.mdx b/apps/docs/content/guides/auth/signout.mdx
index dd932904e60..7bdc0a32fec 100644
--- a/apps/docs/content/guides/auth/signout.mdx
+++ b/apps/docs/content/guides/auth/signout.mdx
@@ -89,6 +89,16 @@ Supabase Auth allows you to specify three different scopes for when a user invok
You can invoke these by providing the `scope` option:
+
+
+JavaScript, Swift, Python, and C# default to the `global` scope. Dart and Kotlin default to `local`.
+
+With the `global` scope, calling `signOut()` on one device revokes the refresh tokens for every session that user has, including other devices and browsers. Those other sessions fail with `AuthApiError: Invalid Refresh Token: Refresh Token Not Found` the next time they try to refresh, even though no one signed out on that device.
+
+If your app expects independent sessions per device, pass the `local` scope explicitly, as shown in the examples below.
+
+
+
{
await expect(article, 'Page article should be present').toBeVisible()
const links = await collectDocsOwnedLinks(page, baseURL!, articleSelector)
- const userAgent = await browserLikeUserAgent(page)
for (const url of links) {
- try {
- const linkResponse = await page.request.get(url, { headers: { 'user-agent': userAgent } })
- expect
- .soft(linkResponse.ok(), `${url} should resolve (status ${linkResponse.status()})`)
- .toBeTruthy()
- } catch (error) {
- expect
- .soft(
- null,
- `${url} should be reachable (${error instanceof Error ? error.message : error})`
- )
- .toBeTruthy()
- }
+ const result = await checkLinkFromBrowser(page, url)
+ expect
+ .soft(
+ result.ok,
+ result.error
+ ? `${url} should be reachable (${result.error})`
+ : `${url} should resolve (status ${result.status})`
+ )
+ .toBeTruthy()
}
})
}
diff --git a/e2e/docs/utils/docs-links.ts b/e2e/docs/utils/docs-links.ts
index 58942279c39..16e88164942 100644
--- a/e2e/docs/utils/docs-links.ts
+++ b/e2e/docs/utils/docs-links.ts
@@ -55,8 +55,23 @@ export async function collectDocsOwnedLinks(
return [...links].sort()
}
-// Vercel bot protection blocks the HeadlessChrome UA on some routes; strip it.
-export async function browserLikeUserAgent(page: Page): Promise {
- const userAgent = await page.evaluate(() => navigator.userAgent)
- return userAgent.replace('HeadlessChrome', 'Chrome')
+export type LinkCheckResult = {
+ ok: boolean
+ status: number
+ error?: string
+}
+
+// The docs middleware rewrites /reference/* requests whose user agent looks like a bot
+// (isbot, which matches HeadlessChrome) to /api/crawlers. Fetching from inside the page
+// keeps the browser's own user agent and network stack, so a link resolves the same way
+// page.goto resolves it in this suite.
+export async function checkLinkFromBrowser(page: Page, url: string): Promise {
+ return page.evaluate(async (linkUrl) => {
+ try {
+ const response = await fetch(linkUrl)
+ return { ok: response.ok, status: response.status }
+ } catch (error) {
+ return { ok: false, status: 0, error: error instanceof Error ? error.message : String(error) }
+ }
+ }, url)
}
diff --git a/scripts/waitForVercelPreview.js b/scripts/waitForVercelPreview.js
index 37dc2d34616..ab38b109779 100644
--- a/scripts/waitForVercelPreview.js
+++ b/scripts/waitForVercelPreview.js
@@ -4,56 +4,162 @@
// builds fine. Poll the project's Vercel commit status instead, then resolve
// the actual preview URL via Vercel's own deployments API.
//
+// A build that Vercel's Ignored Build Step skips still gets a successful
+// commit status, but the deployment behind it is CANCELED and its URL serves
+// a placeholder page with a 200 status. Such a preview is not testable, so
+// the URL is resolved from the newest earlier commit of the pull request
+// whose deployment is READY. Vercel skips a build only when the commit does
+// not affect the project, so that earlier preview serves the same content.
+//
// Set VERCEL_STATUS_CONTEXT to the project's commit status name, e.g.
-// "Vercel – docs" or "Vercel – zone-www-dot-com".
+// "Vercel – docs" or "Vercel – zone-www-dot-com". PR_NUMBER enables the
+// fallback to earlier commits of the pull request.
const { appendFileSync } = require('fs')
const TIMEOUT_MS = 900_000
const POLL_INTERVAL_MS = 15_000
+const GITHUB_PAGE_SIZE = 100
-async function fetchLatestStatus(repository, sha, githubToken, statusContext) {
- const url = `https://api.github.com/repos/${repository}/commits/${sha}/statuses`
- const response = await fetch(url, {
- headers: {
- Authorization: `Bearer ${githubToken}`,
- Accept: 'application/vnd.github+json',
- },
- })
-
- if (!response.ok) {
- throw new Error(`Failed to fetch commit statuses: ${response.status} ${response.statusText}`)
+function deploymentIdFromTargetUrl(targetUrl) {
+ let rawId
+ try {
+ rawId = new URL(targetUrl).pathname.split('/').filter(Boolean).pop()
+ } catch {
+ rawId = undefined
}
-
- const statuses = await response.json()
-
- return statuses
- .filter((status) => status.context === statusContext)
- .sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())[0]
-}
-
-async function resolveDeploymentUrl(targetUrl, vercelToken, teamId) {
- const rawId = targetUrl.split('/').filter(Boolean).pop()
if (!rawId) {
throw new Error(`Could not parse a deployment ID from target_url: ${targetUrl}`)
}
- const deploymentId = rawId.startsWith('dpl_') ? rawId : `dpl_${rawId}`
+ return rawId.startsWith('dpl_') ? rawId : `dpl_${rawId}`
+}
- const url = teamId
- ? `https://api.vercel.com/v13/deployments/${deploymentId}?teamId=${teamId}`
- : `https://api.vercel.com/v13/deployments/${deploymentId}`
+function shortSha(sha) {
+ return sha.slice(0, 10)
+}
- const response = await fetch(url, {
- headers: { Authorization: `Bearer ${vercelToken}` },
- })
+function createClients({ fetchImpl = fetch, repository, githubToken, vercelToken, teamId }) {
+ const githubHeaders = {
+ Authorization: `Bearer ${githubToken}`,
+ Accept: 'application/vnd.github+json',
+ }
+ const vercelHeaders = { Authorization: `Bearer ${vercelToken}` }
- if (!response.ok) {
+ async function getJson(url, headers, what) {
+ const response = await fetchImpl(url, { headers })
+ if (!response.ok) {
+ throw new Error(`Failed to ${what}: ${response.status} ${response.statusText}`)
+ }
+ return response.json()
+ }
+
+ return {
+ // Newest status for the context, or undefined when Vercel has not posted one.
+ async latestStatus(sha, statusContext) {
+ const statuses = await getJson(
+ `https://api.github.com/repos/${repository}/commits/${sha}/statuses`,
+ githubHeaders,
+ `fetch commit statuses for ${shortSha(sha)}`
+ )
+ return statuses
+ .filter((status) => status.context === statusContext)
+ .sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())[0]
+ },
+
+ // Commit SHAs of the pull request, newest first.
+ async pullRequestShas(prNumber) {
+ const shas = []
+ for (let page = 1; ; page += 1) {
+ const commits = await getJson(
+ `https://api.github.com/repos/${repository}/pulls/${prNumber}/commits?per_page=${GITHUB_PAGE_SIZE}&page=${page}`,
+ githubHeaders,
+ `fetch commits for pull request #${prNumber}`
+ )
+ shas.push(...commits.map((commit) => commit.sha))
+ if (commits.length < GITHUB_PAGE_SIZE) break
+ }
+ return shas.reverse()
+ },
+
+ async deployment(targetUrl) {
+ const deploymentId = deploymentIdFromTargetUrl(targetUrl)
+ const query = teamId ? `?teamId=${teamId}` : ''
+ const deployment = await getJson(
+ `https://api.vercel.com/v13/deployments/${deploymentId}${query}`,
+ vercelHeaders,
+ `resolve Vercel deployment ${deploymentId}`
+ )
+ return { url: `https://${deployment.url}`, state: deployment.readyState ?? deployment.status }
+ },
+ }
+}
+
+async function resolveFromEarlierCommits(clients, { headSha, prNumber, statusContext, log }) {
+ if (!prNumber) {
throw new Error(
- `Failed to resolve Vercel deployment ${deploymentId}: ${response.status} ${response.statusText}`
+ `The "${statusContext}" build for ${shortSha(headSha)} was skipped, and PR_NUMBER is required to fall back to an earlier commit's preview`
)
}
- const deployment = await response.json()
- return `https://${deployment.url}`
+ const shas = await clients.pullRequestShas(prNumber)
+ for (const sha of shas) {
+ if (sha === headSha) continue
+
+ const status = await clients.latestStatus(sha, statusContext)
+ if (status?.state !== 'success' || !status.target_url) continue
+
+ const deployment = await clients.deployment(status.target_url)
+ if (deployment.state === 'READY') {
+ log(`Using the "${statusContext}" preview built for ${shortSha(sha)}: ${deployment.url}`)
+ return deployment.url
+ }
+ }
+
+ throw new Error(
+ `No READY "${statusContext}" preview among the ${shas.length} commits of pull request #${prNumber}`
+ )
+}
+
+async function resolvePreviewUrl(clients, options) {
+ const { headSha, statusContext, timeoutMs, pollIntervalMs, sleep, now, log } = options
+ const start = now()
+
+ for (;;) {
+ const latest = await clients.latestStatus(headSha, statusContext)
+
+ if (latest?.state === 'success') {
+ if (!latest.target_url) {
+ throw new Error(
+ `"${statusContext}" commit status succeeded but had no target_url to resolve a deployment from`
+ )
+ }
+
+ const deployment = await clients.deployment(latest.target_url)
+ if (deployment.state === 'READY') {
+ return deployment.url
+ }
+ if (deployment.state === 'CANCELED') {
+ log(
+ `"${statusContext}" for ${shortSha(headSha)} reports "${latest.description}" and its deployment is CANCELED; looking for the newest READY preview among earlier commits`
+ )
+ return resolveFromEarlierCommits(clients, options)
+ }
+ throw new Error(
+ `"${statusContext}" deployment for ${shortSha(headSha)} is ${deployment.state}, not READY`
+ )
+ }
+
+ if (latest?.state === 'failure' || latest?.state === 'error') {
+ throw new Error(`"${statusContext}" deployment failed (commit status: ${latest.state})`)
+ }
+
+ if (now() - start > timeoutMs) {
+ throw new Error(
+ `Timed out after ${Math.round(timeoutMs / 1000)}s waiting for the "${statusContext}" preview deployment`
+ )
+ }
+
+ await sleep(pollIntervalMs)
+ }
}
function writeOutput(name, value) {
@@ -71,6 +177,7 @@ async function main() {
const vercelToken = process.env.VERCEL_TOKEN
const teamId = process.env.VERCEL_TEAM_ID
const statusContext = process.env.VERCEL_STATUS_CONTEXT
+ const prNumber = process.env.PR_NUMBER
if (!repository) throw new Error('GITHUB_REPOSITORY environment variable is required')
if (!sha) throw new Error('HEAD_SHA environment variable is required')
@@ -78,35 +185,25 @@ async function main() {
if (!vercelToken) throw new Error('VERCEL_TOKEN environment variable is required')
if (!statusContext) throw new Error('VERCEL_STATUS_CONTEXT environment variable is required')
- const start = Date.now()
-
- for (;;) {
- const latest = await fetchLatestStatus(repository, sha, githubToken, statusContext)
-
- if (latest?.state === 'success') {
- if (!latest.target_url) {
- throw new Error(
- `"${statusContext}" commit status succeeded but had no target_url to resolve a deployment from`
- )
- }
- const deploymentUrl = await resolveDeploymentUrl(latest.target_url, vercelToken, teamId)
- writeOutput('deployment-url', deploymentUrl)
- return
- }
-
- if (latest?.state === 'failure' || latest?.state === 'error') {
- throw new Error(`"${statusContext}" deployment failed (commit status: ${latest.state})`)
- }
-
- if (Date.now() - start > TIMEOUT_MS) {
- throw new Error(`Timed out after 900s waiting for the "${statusContext}" preview deployment`)
- }
-
- await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS))
- }
+ const clients = createClients({ repository, githubToken, vercelToken, teamId })
+ const url = await resolvePreviewUrl(clients, {
+ headSha: sha,
+ prNumber,
+ statusContext,
+ timeoutMs: TIMEOUT_MS,
+ pollIntervalMs: POLL_INTERVAL_MS,
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
+ now: Date.now,
+ log: console.log,
+ })
+ writeOutput('deployment-url', url)
}
-main().catch((error) => {
- console.error('Fatal error:', error)
- process.exit(1)
-})
+module.exports = { createClients, deploymentIdFromTargetUrl, resolvePreviewUrl }
+
+if (require.main === module) {
+ main().catch((error) => {
+ console.error('Fatal error:', error)
+ process.exit(1)
+ })
+}
diff --git a/scripts/waitForVercelPreview.test.js b/scripts/waitForVercelPreview.test.js
new file mode 100644
index 00000000000..0c74acdd864
--- /dev/null
+++ b/scripts/waitForVercelPreview.test.js
@@ -0,0 +1,196 @@
+const assert = require('node:assert/strict')
+const { test } = require('node:test')
+
+const { deploymentIdFromTargetUrl, resolvePreviewUrl } = require('./waitForVercelPreview.js')
+
+const CONTEXT = 'Vercel – docs'
+const HEAD = 'head000000000000000000000000000000000000'
+const OLDER = 'older00000000000000000000000000000000000'
+const OLDEST = 'oldest0000000000000000000000000000000000'
+
+function success(id, description = 'Deployment has completed') {
+ return { state: 'success', description, target_url: `https://vercel.com/supabase/docs/${id}` }
+}
+
+function fakeClients({ statuses = {}, deployments = {}, prShas = [] }) {
+ const calls = { latestStatus: [], deployment: [], pullRequestShas: 0 }
+ return {
+ calls,
+ async latestStatus(sha) {
+ calls.latestStatus.push(sha)
+ const list = statuses[sha]
+ return Array.isArray(list) ? list.shift() : list
+ },
+ async pullRequestShas() {
+ calls.pullRequestShas += 1
+ return prShas
+ },
+ async deployment(targetUrl) {
+ calls.deployment.push(targetUrl)
+ return deployments[deploymentIdFromTargetUrl(targetUrl)]
+ },
+ }
+}
+
+function options(overrides = {}) {
+ const log = []
+ return {
+ headSha: HEAD,
+ prNumber: 50119,
+ statusContext: CONTEXT,
+ timeoutMs: 1000,
+ pollIntervalMs: 10,
+ sleep: async () => {},
+ now: () => 0,
+ log: (line) => log.push(line),
+ logLines: log,
+ ...overrides,
+ }
+}
+
+test('deploymentIdFromTargetUrl prefixes bare inspector ids and keeps dpl_ ids', () => {
+ assert.equal(
+ deploymentIdFromTargetUrl('https://vercel.com/supabase/docs/Avoo1vcp'),
+ 'dpl_Avoo1vcp'
+ )
+ assert.equal(
+ deploymentIdFromTargetUrl('https://vercel.com/supabase/docs/dpl_Avoo1vcp'),
+ 'dpl_Avoo1vcp'
+ )
+ assert.throws(() => deploymentIdFromTargetUrl('https://vercel.com'), /deployment ID/)
+})
+
+test('returns the head deployment when it is READY', async () => {
+ const clients = fakeClients({
+ statuses: { [HEAD]: success('dpl_head') },
+ deployments: { dpl_head: { url: 'https://docs-head.vercel.app', state: 'READY' } },
+ })
+
+ const url = await resolvePreviewUrl(clients, options())
+
+ assert.equal(url, 'https://docs-head.vercel.app')
+ assert.equal(clients.calls.pullRequestShas, 0)
+})
+
+test('polls while the head status is pending', async () => {
+ let clock = 0
+ const sleeps = []
+ const clients = fakeClients({
+ statuses: { [HEAD]: [undefined, { state: 'pending' }, success('dpl_head')] },
+ deployments: { dpl_head: { url: 'https://docs-head.vercel.app', state: 'READY' } },
+ })
+
+ const url = await resolvePreviewUrl(
+ clients,
+ options({
+ now: () => clock,
+ sleep: async (ms) => {
+ sleeps.push(ms)
+ clock += ms
+ },
+ })
+ )
+
+ assert.equal(url, 'https://docs-head.vercel.app')
+ assert.deepEqual(sleeps, [10, 10])
+})
+
+test('falls back to the newest earlier commit with a READY deployment when the head build was skipped', async () => {
+ const clients = fakeClients({
+ prShas: [HEAD, OLDER, OLDEST],
+ statuses: {
+ [HEAD]: success('dpl_head', 'Skipped - Not affected'),
+ [OLDER]: undefined,
+ [OLDEST]: success('dpl_oldest'),
+ },
+ deployments: {
+ dpl_head: { url: 'https://docs-head.vercel.app', state: 'CANCELED' },
+ dpl_oldest: { url: 'https://docs-oldest.vercel.app', state: 'READY' },
+ },
+ })
+ const opts = options()
+
+ const url = await resolvePreviewUrl(clients, opts)
+
+ assert.equal(url, 'https://docs-oldest.vercel.app')
+ assert.deepEqual(clients.calls.latestStatus, [HEAD, OLDER, OLDEST])
+ assert.ok(opts.logLines.some((line) => line.includes('Skipped - Not affected')))
+ assert.ok(opts.logLines.some((line) => line.includes(OLDEST.slice(0, 10))))
+})
+
+test('skips earlier commits whose deployment is not READY', async () => {
+ const clients = fakeClients({
+ prShas: [HEAD, OLDER, OLDEST],
+ statuses: {
+ [HEAD]: success('dpl_head', 'Skipped - Not affected'),
+ [OLDER]: success('dpl_older', 'Skipped - Not affected'),
+ [OLDEST]: success('dpl_oldest'),
+ },
+ deployments: {
+ dpl_head: { url: 'https://docs-head.vercel.app', state: 'CANCELED' },
+ dpl_older: { url: 'https://docs-older.vercel.app', state: 'CANCELED' },
+ dpl_oldest: { url: 'https://docs-oldest.vercel.app', state: 'READY' },
+ },
+ })
+
+ const url = await resolvePreviewUrl(clients, options())
+
+ assert.equal(url, 'https://docs-oldest.vercel.app')
+})
+
+test('throws when the head build was skipped and no earlier commit has a READY deployment', async () => {
+ const clients = fakeClients({
+ prShas: [HEAD, OLDER],
+ statuses: {
+ [HEAD]: success('dpl_head', 'Skipped - Not affected'),
+ [OLDER]: { state: 'failure', target_url: 'https://vercel.com/supabase/docs/dpl_older' },
+ },
+ deployments: { dpl_head: { url: 'https://docs-head.vercel.app', state: 'CANCELED' } },
+ })
+
+ await assert.rejects(resolvePreviewUrl(clients, options()), /No READY "Vercel – docs" preview/)
+})
+
+test('throws when the head build was skipped and no pull request number is available', async () => {
+ const clients = fakeClients({
+ statuses: { [HEAD]: success('dpl_head', 'Skipped - Not affected') },
+ deployments: { dpl_head: { url: 'https://docs-head.vercel.app', state: 'CANCELED' } },
+ })
+
+ await assert.rejects(resolvePreviewUrl(clients, options({ prNumber: undefined })), /PR_NUMBER/)
+ assert.equal(clients.calls.pullRequestShas, 0)
+})
+
+test('throws when the head deployment is in any state other than READY or CANCELED', async () => {
+ const clients = fakeClients({
+ statuses: { [HEAD]: success('dpl_head') },
+ deployments: { dpl_head: { url: 'https://docs-head.vercel.app', state: 'ERROR' } },
+ })
+
+ await assert.rejects(resolvePreviewUrl(clients, options()), /ERROR/)
+})
+
+test('throws when the head status reports a failed deployment', async () => {
+ const clients = fakeClients({ statuses: { [HEAD]: { state: 'failure' } } })
+
+ await assert.rejects(resolvePreviewUrl(clients, options()), /deployment failed/)
+})
+
+test('throws when the head status never resolves before the timeout', async () => {
+ let clock = 0
+ const clients = fakeClients({ statuses: { [HEAD]: { state: 'pending' } } })
+
+ await assert.rejects(
+ resolvePreviewUrl(
+ clients,
+ options({
+ timeoutMs: 25,
+ now: () => clock,
+ sleep: async (ms) => {
+ clock += ms
+ },
+ })
+ ),
+ /Timed out/
+ )
+})