From 61758c87273c84d4cab6c74a0f9da91c88c710b0 Mon Sep 17 00:00:00 2001 From: huanghuoguoguo <60681390+huanghuoguoguo@users.noreply.github.com> Date: Sat, 17 Oct 2026 16:31:51 +0800 Subject: [PATCH 1/2] test(web): replace account source checks with browser assertions --- web/tests/e2e/account-oauth.spec.ts | 103 ++++++++++++ web/tests/e2e/bot-form-layout.spec.ts | 133 +++++++++++++++ web/tests/e2e/cloud-storage-privacy.spec.ts | 49 ++++++ web/tests/e2e/invitations.spec.ts | 4 + web/tests/e2e/owner-space-billing.spec.ts | 156 +++++++++++------- web/tests/e2e/plugin-page-auth.spec.ts | 120 ++++++++++++++ .../unit/cloud-new-account-entry.test.mjs | 19 --- .../unit/oss-account-space-billing.test.mjs | 39 ----- web/tests/unit/oss-cloud-ui-privacy.test.mjs | 108 ------------ 9 files changed, 504 insertions(+), 227 deletions(-) create mode 100644 web/tests/e2e/account-oauth.spec.ts create mode 100644 web/tests/e2e/bot-form-layout.spec.ts delete mode 100644 web/tests/unit/cloud-new-account-entry.test.mjs diff --git a/web/tests/e2e/account-oauth.spec.ts b/web/tests/e2e/account-oauth.spec.ts new file mode 100644 index 000000000..927fb0fda --- /dev/null +++ b/web/tests/e2e/account-oauth.spec.ts @@ -0,0 +1,103 @@ +import { expect, test } from '@playwright/test'; +import { installLangBotApiMocks } from './fixtures/langbot-api'; + +for (const invitation of [false, true]) { + test(`Cloud login starts standard OAuth, invitation=${invitation}`, async ({ + page, + }) => { + await installLangBotApiMocks(page); + await page.route('**/api/v1/system/info', (route) => + route.fulfill({ + json: { + code: 0, + data: { + edition: 'cloud', + deployment_mode: 'cloud', + cloud_service_url: 'https://space.langbot.app', + wizard_status: 'completed', + }, + }, + }), + ); + await page.route('**/api/v1/user/account-info', (route) => + route.fulfill({ + json: { + code: 0, + data: { + initialized: true, + password_login_enabled: false, + space_login_enabled: true, + authenticated_invitation_acceptance_enabled: true, + }, + }, + }), + ); + if (invitation) + await page.addInitScript(() => + sessionStorage.setItem( + 'langbot_pending_invitation_token', + 'invited-token', + ), + ); + let redirectUri: string | null = null; + await page.route('**/api/v1/user/space/authorize-url**', async (route) => { + redirectUri = new URL(route.request().url()).searchParams.get( + 'redirect_uri', + ); + await route.fulfill({ + json: { code: 0, data: { authorize_url: '/oauth-provider' } }, + }); + }); + await page.route('**/oauth-provider', (route) => + route.fulfill({ + contentType: 'text/html', + body: '

OAuth provider

', + }), + ); + await page.goto(invitation ? '/login?invitation=1' : '/login'); + await page + .getByRole('button', { name: 'Login with LangBot Account', exact: true }) + .click(); + await expect(page).toHaveURL(/\/oauth-provider$/); + expect(redirectUri).toBe(new URL('/auth/space/callback', page.url()).href); + expect( + await page.evaluate(() => + sessionStorage.getItem('langbot_pending_invitation_token'), + ), + ).toBe(invitation ? 'invited-token' : null); + }); +} + +for (const [code, title, message] of [ + [ + 'space_account_not_registered', + 'Account not registered', + 'No local account is registered for this LangBot Account email. Ask the Workspace owner for an invitation.', + ], + [ + 'space_account_binding_required', + 'LangBot Account connection required', + 'This local account must connect a LangBot Account from Account settings before using LangBot Account login.', + ], +]) { + test(`OAuth callback explains ${code}`, async ({ page }) => { + await installLangBotApiMocks(page); + await page.route('**/api/v1/user/space/callback', (route) => + route.fulfill({ + status: 403, + json: { code, msg: 'Generic upstream message' }, + }), + ); + await page.goto('/auth/space/callback?code=code&state=state'); + await expect(page.getByText(title, { exact: true })).toBeVisible(); + await expect(page.getByText(message, { exact: true })).toBeVisible(); + await expect( + page.getByText('Generic upstream message', { exact: true }), + ).toHaveCount(0); + await page + .getByRole('button', { name: 'Back to Login', exact: true }) + .click(); + await expect(page).toHaveURL(/\/login$/); + expect(await page.evaluate(() => localStorage.getItem('token'))).toBeNull(); + }); +} diff --git a/web/tests/e2e/bot-form-layout.spec.ts b/web/tests/e2e/bot-form-layout.spec.ts new file mode 100644 index 000000000..978aad807 --- /dev/null +++ b/web/tests/e2e/bot-form-layout.spec.ts @@ -0,0 +1,133 @@ +import { expect, test } from '@playwright/test'; +import { installLangBotApiMocks } from './fixtures/langbot-api'; + +for (const width of [1440, 390]) { + test(`bot setup and detail columns stay separated at ${width}px`, async ({ + page, + }) => { + await page.setViewportSize({ width, height: 1000 }); + await installLangBotApiMocks(page, { authenticated: true }); + await page.goto('/home/bots?id=new'); + const layout = page.locator('#bot-form > fieldset'); + const basic = layout.locator('[data-guide="bot-basic"]'); + const adapter = page.getByTestId('adapter-gallery'); + await expect(basic).toBeVisible(); + await expect(adapter).toBeVisible(); + const basicBox = (await basic.boundingBox())!; + const adapterBox = (await adapter.boundingBox())!; + if (width >= 1024) + expect(adapterBox.x).toBeGreaterThan(basicBox.x + basicBox.width); + else expect(adapterBox.y).toBeGreaterThan(basicBox.y + basicBox.height); + await adapter.getByRole('button', { name: /Playwright Adapter/ }).click(); + await page.locator('input[name="name"]').fill('Layout bot'); + await page.getByRole('button', { name: /^Submit$/ }).click(); + await expect(page).toHaveURL(/\/home\/bots\?id=bot-1$/); + const routing = page.locator('[data-guide="bot-routing"]'); + await expect(routing).toBeVisible(); + const cards = await layout.locator(':scope > [data-slot="card"]').all(); + expect(cards).toHaveLength(2); + const left = (await cards[0].boundingBox())!; + const right = (await cards[1].boundingBox())!; + if (width >= 1024) expect(right.x).toBeGreaterThan(left.x + left.width); + else expect(right.y).toBeGreaterThan(left.y + left.height); + expect( + await page.evaluate( + () => document.documentElement.scrollWidth <= innerWidth, + ), + ).toBe(true); + await page.goto('/home/knowledge?id=new'); + const form = page.locator('#kb-form'); + await expect(form).toBeVisible(); + const children = await form.locator(':scope > *').all(); + expect(children.length).toBeGreaterThan(1); + for (let i = 1; i < children.length; i++) { + const previous = await children[i - 1].boundingBox(); + const current = await children[i].boundingBox(); + if (previous && current) + expect(current.y - previous.y - previous.height).toBeGreaterThanOrEqual( + 20, + ); + } + }); +} + +test('the dedicated connection selector hides its manifest controller and persists its value', async ({ + page, +}) => { + await installLangBotApiMocks(page, { authenticated: true }); + await page.route('**/api/v1/platform/adapters', (route) => + route.fulfill({ + json: { + code: 0, + data: { + adapters: [ + { + name: 'connection-adapter', + label: { en_US: 'Connection adapter' }, + description: { en_US: 'Both connection modes' }, + spec: { + categories: ['testing'], + config: [ + { + name: 'transport', + label: { en_US: 'Raw transport controller' }, + type: 'select', + required: true, + default: 'persistent', + options: [ + { name: 'persistent', label: { en_US: 'Persistent' } }, + { + name: 'webhook', + label: { en_US: 'Webhook transport' }, + }, + ], + }, + { + name: 'webhook_url', + label: { en_US: 'Webhook address' }, + type: 'webhook-url', + show_if: { + field: 'transport', + operator: 'eq', + value: 'webhook', + }, + }, + { + name: 'credential', + label: { en_US: 'Adapter credential' }, + type: 'string', + required: true, + default: '', + }, + ], + }, + }, + ], + }, + }, + }), + ); + await page.goto('/home/bots?id=new'); + await page + .getByTestId('adapter-gallery') + .getByRole('button', { name: /Connection adapter/ }) + .click(); + await page.getByRole('button', { name: /^Submit$/ }).click(); + await expect(page).toHaveURL(/\/home\/bots\?id=bot-1$/); + await page.getByRole('radio', { name: /^Webhook/ }).click(); + await expect(page.getByText(/^Raw transport controller/)).toHaveCount(0); + await expect( + page.getByText('Webhook address', { exact: true }), + ).toBeVisible(); + await page.locator('input[name="credential"]').fill('persist-me'); + const save = page.waitForRequest( + (request) => + request.method() === 'PUT' && + request.url().endsWith('/api/v1/platform/bots/bot-1'), + ); + await page.getByRole('button', { name: /^Save and enable$/ }).click(); + expect((await save).postDataJSON().adapter_config).toEqual({ + transport: 'webhook', + credential: 'persist-me', + }); +}); diff --git a/web/tests/e2e/cloud-storage-privacy.spec.ts b/web/tests/e2e/cloud-storage-privacy.spec.ts index c28cf24a0..d9c69edff 100644 --- a/web/tests/e2e/cloud-storage-privacy.spec.ts +++ b/web/tests/e2e/cloud-storage-privacy.spec.ts @@ -61,3 +61,52 @@ test('Cloud never exposes or requests storage analysis', async ({ page }) => { ); expect(storageAnalysisRequests).toBe(0); }); + +test('an OSS owner opens storage analysis from its storage-icon menu item', async ({ + page, +}) => { + await installLangBotApiMocks(page, { authenticated: true }); + let requests = 0; + await page.route('**/api/v1/system/storage-analysis', async (route) => { + requests++; + await route.fulfill({ + json: { + code: 0, + data: { + generated_at: '2026-10-17T00:00:00Z', + total_size_bytes: 123, + sections: [], + cleanup_policy: { + uploaded_file_retention_days: 7, + log_retention_days: 14, + }, + database: { + type: 'sqlite', + monitoring_counts: {}, + binary_storage: { count: 0, size_bytes: 0 }, + }, + cleanup_candidates: { uploaded_files: [], log_files: [] }, + tasks: {}, + }, + }, + }); + }); + await page.goto('/home/bots'); + await page.getByRole('button', { name: /admin@example\.com/i }).click(); + const menuItem = page.getByRole('menuitem', { + name: 'Storage Analysis', + exact: true, + }); + await expect(menuItem).toBeVisible(); + await expect(menuItem.locator('svg.lucide-hard-drive')).toBeVisible(); + await menuItem.click(); + await expect( + page + .getByRole('dialog') + .getByRole('button', { name: 'Refresh', exact: true }), + ).toBeVisible(); + await expect( + page.getByRole('dialog').getByText('123 B', { exact: true }), + ).toBeVisible(); + await expect.poll(() => requests).toBeGreaterThan(0); +}); diff --git a/web/tests/e2e/invitations.spec.ts b/web/tests/e2e/invitations.spec.ts index b9f77cfb4..ccf3908f8 100644 --- a/web/tests/e2e/invitations.spec.ts +++ b/web/tests/e2e/invitations.spec.ts @@ -214,6 +214,7 @@ test('an OAuth-only OSS instance registers the invited email with a local passwo email: 'invited@example.com', password: 'invite-password-123', }); + expect(await page.evaluate(() => localStorage.getItem('token'))).toBeNull(); }); test('an authenticated OSS invitation requires logout before registration', async ({ @@ -402,8 +403,10 @@ test('Space OAuth accepts a pending invitation with the freshly authenticated ac }); let acceptanceAuthorization = ''; + let acceptanceBody: unknown; await page.route('**/api/v1/invitations/accept', async (route) => { acceptanceAuthorization = route.request().headers().authorization ?? ''; + acceptanceBody = route.request().postDataJSON(); await route.fulfill({ status: 200, contentType: 'application/json', @@ -424,6 +427,7 @@ test('Space OAuth accepts a pending invitation with the freshly authenticated ac timeout: 5_000, }); expect(acceptanceAuthorization).toBe('Bearer fresh-invited-account-token'); + expect(acceptanceBody).toEqual({ token: 'matching-invitation' }); expect( await page.evaluate(() => ({ token: localStorage.getItem('token'), diff --git a/web/tests/e2e/owner-space-billing.spec.ts b/web/tests/e2e/owner-space-billing.spec.ts index 4db78e3f6..60be5d490 100644 --- a/web/tests/e2e/owner-space-billing.spec.ts +++ b/web/tests/e2e/owner-space-billing.spec.ts @@ -1,64 +1,98 @@ import { expect, test } from '@playwright/test'; +import { + installLangBotApiMocks, + makeWorkspaceEntry, +} from './fixtures/langbot-api'; -import { installLangBotApiMocks } from './fixtures/langbot-api'; - -test('an OSS local-only owner is prompted to bind before using LangBot Models', async ({ - page, -}) => { - await installLangBotApiMocks(page, { authenticated: true }); - await page.route('**/api/v1/user/space-credits', (route) => - route.fulfill({ - contentType: 'application/json', - body: JSON.stringify({ - code: 0, - data: { - credits: null, - owner_space_bound: false, - is_workspace_owner: true, - }, - msg: 'ok', - }), - }), - ); - await page.route('**/api/v1/provider/providers', (route) => - route.fulfill({ - contentType: 'application/json', - body: JSON.stringify({ - code: 0, - data: { - providers: [ - { - uuid: 'langbot-models-provider', - name: 'LangBot Models', - requester: 'space-chat-completions', - base_url: '', - api_keys: [], +for (const role of ['owner', 'viewer'] as const) { + for (const bound of [false, true]) { + test(`LangBot Models uses Workspace billing for ${role}, owner bound=${bound}`, async ({ + page, + }) => { + const workspace = makeWorkspaceEntry( + 'billing-workspace', + 'Billing Workspace', + 'local', + ); + workspace.membership.role = role; + await installLangBotApiMocks(page, { + authenticated: true, + workspaces: [workspace], + }); + const billingHeaders: Array> = []; + await page.route('**/api/v1/user/space-credits', async (route) => { + billingHeaders.push(route.request().headers()); + await route.fulfill({ + json: { + code: 0, + data: { + credits: bound ? 12500 : null, + owner_space_bound: bound, + is_workspace_owner: role === 'owner', }, - ], - }, - msg: 'ok', - }), - }), - ); - await page.route('**/api/v1/provider/requesters**', (route) => - route.fulfill({ - contentType: 'application/json', - body: JSON.stringify({ code: 0, data: { requesters: [] }, msg: 'ok' }), - }), - ); - await page.route('**/api/v1/provider/models/**', (route) => - route.fulfill({ - contentType: 'application/json', - body: JSON.stringify({ code: 0, data: { models: [] }, msg: 'ok' }), - }), - ); - - await page.goto('/home?action=showModelSettings'); - - await expect( - page.getByText( - 'The Workspace owner must connect a LangBot Account for LangBot Models.', - { exact: true }, - ), - ).toBeVisible(); -}); + }, + }); + }); + await page.route('**/api/v1/provider/providers', (route) => + route.fulfill({ + json: { + code: 0, + data: { + providers: [ + { + uuid: 'langbot-models-provider', + name: 'LangBot Models', + requester: 'space-chat-completions', + base_url: '', + api_keys: [], + }, + ], + }, + }, + }), + ); + await page.route('**/api/v1/provider/requesters**', (route) => + route.fulfill({ json: { code: 0, data: { requesters: [] } } }), + ); + await page.route('**/api/v1/provider/models/**', (route) => + route.fulfill({ json: { code: 0, data: { models: [] } } }), + ); + await page.goto('/home?action=showModelSettings'); + await expect.poll(() => billingHeaders.length).toBeGreaterThan(0); + expect(billingHeaders[0]).toMatchObject({ + authorization: 'Bearer playwright-token', + 'x-workspace-id': 'billing-workspace', + }); + const dialog = page.getByRole('dialog'); + await expect( + dialog.getByText( + 'The Workspace owner must connect a LangBot Account for LangBot Models.', + { exact: true }, + ), + ).toHaveCount(bound ? 0 : 1); + await expect( + dialog.getByRole('button', { + name: 'Login with LangBot Account', + exact: true, + }), + ).toHaveCount(role === 'owner' && !bound ? 1 : 0); + await expect( + dialog.getByText('2.50 Credits', { exact: true }), + ).toHaveCount(bound ? 1 : 0); + if (bound) { + await expect( + dialog + .getByText('2.50 Credits', { exact: true }) + .locator('..') + .getByRole('button'), + ).toHaveCount(role === 'owner' ? 1 : 0); + } + await expect( + dialog.getByText( + "Uses the Workspace owner's LangBot Account billing and credits.", + { exact: true }, + ), + ).toHaveCount(role === 'viewer' && bound ? 1 : 0); + }); + } +} diff --git a/web/tests/e2e/plugin-page-auth.spec.ts b/web/tests/e2e/plugin-page-auth.spec.ts index 384567120..7cc477cc7 100644 --- a/web/tests/e2e/plugin-page-auth.spec.ts +++ b/web/tests/e2e/plugin-page-auth.spec.ts @@ -62,6 +62,7 @@ test('loads a Cloud plugin page through the authenticated asset route', async ({ }); let authenticatedAssetRequests = 0; + const assetHeaders: Array> = []; let pageSdkRequests = 0; let pageApiRequests = 0; await page.route('**/api/v1/plugins/_sdk/page-sdk.js', async (route) => { @@ -91,6 +92,7 @@ test('loads a Cloud plugin page through the authenticated asset route', async ({ '**/api/v1/plugins/langbot-team/LangRAG/authenticated-assets/**', async (route) => { authenticatedAssetRequests += 1; + assetHeaders.push(route.request().headers()); await route.fulfill({ status: 200, contentType: 'text/html', @@ -135,7 +137,125 @@ test('loads a Cloud plugin page through the authenticated asset route', async ({ 'true', ); expect(authenticatedAssetRequests).toBeGreaterThan(0); + expect(assetHeaders[0]).toMatchObject({ + authorization: 'Bearer playwright-token', + 'x-workspace-id': 'workspace-cloud', + }); expect(pageSdkRequests).toBe(1); expect(pageApiRequests).toBe(1); await expect(page.getByText('Loading...')).toHaveCount(0); }); + +test('plugin asset hook replaces and revokes URLs on a same-document Workspace change', async ({ + page, +}) => { + const first = makeWorkspaceEntry('asset-first', 'First', 'local'); + const second = makeWorkspaceEntry('asset-second', 'Second', 'local'); + await installLangBotApiMocks(page, { + authenticated: true, + workspaces: [first], + }); + const headers: Array> = []; + await page.route( + '**/api/v1/plugins/test/asset/authenticated-assets/page.html', + async (route) => { + headers.push(route.request().headers()); + await route.fulfill({ + contentType: 'text/html', + body: `

${headers.at(-1)?.['x-workspace-id']}

`, + }); + }, + ); + await page.goto('/home/bots'); + await expect + .poll(() => + page.evaluate(async () => { + const path = '/src/app/infra/http/currentWorkspaceStore.ts'; + return (await import(path)).getCurrentWorkspaceSnapshot()?.workspace + .uuid; + }), + ) + .toBe('asset-first'); + await page.evaluate(async () => { + const resources = performance + .getEntriesByType('resource') + .map((entry) => entry.name); + const reactPath = resources.find((url) => + new URL(url).pathname.endsWith('/react.js'), + )!; + const domPath = resources.find((url) => + new URL(url).pathname.endsWith('/react-dom_client.js'), + )!; + const React = (await import(reactPath)).default; + const domModule = await import(domPath); + const { createRoot } = domModule.default || domModule; + const hookPath = '/src/hooks/useAuthenticatedPluginResource.ts'; + const { useAuthenticatedPluginAsset } = await import(hookPath); + const host = document.createElement('main'); + host.id = 'asset-hook'; + document.getElementById('root')!.style.display = 'none'; + document.body.appendChild(host); + const revoke = URL.revokeObjectURL.bind(URL); + URL.revokeObjectURL = (url) => { + host.dataset.revoked = JSON.stringify([ + ...JSON.parse(host.dataset.revoked || '[]'), + url, + ]); + revoke(url); + }; + function Resource() { + const resource = useAuthenticatedPluginAsset( + 'test', + 'asset', + 'page.html', + ); + return React.createElement( + 'output', + { 'data-testid': 'asset-url' }, + resource.url, + ); + } + function Harness() { + const [mounted, setMounted] = React.useState(true); + return React.createElement( + React.Fragment, + null, + mounted && React.createElement(Resource), + React.createElement( + 'button', + { onClick: () => setMounted(false) }, + 'Unmount resource', + ), + ); + } + createRoot(host).render(React.createElement(Harness)); + }); + const output = page.getByTestId('asset-url'); + await expect(output).toHaveText(/^blob:/); + const oldURL = await output.textContent(); + await page.evaluate(async (workspace) => { + const contextPath = '/src/app/infra/http/workspaceContext.ts'; + const storePath = '/src/app/infra/http/currentWorkspaceStore.ts'; + const context = await import(contextPath); + const store = await import(storePath); + context.setActiveWorkspaceUuid(workspace.workspace.uuid); + store.setCurrentWorkspaceSnapshot(workspace); + }, second); + await expect(output).toHaveText(/^blob:/); + await expect(output).not.toHaveText(oldURL!); + await expect + .poll(() => headers.map((item) => item['x-workspace-id'])) + .toEqual(['asset-first', 'asset-second']); + expect( + headers.every((item) => item.authorization === 'Bearer playwright-token'), + ).toBe(true); + const newURL = await output.textContent(); + await expect + .poll(() => page.locator('#asset-hook').getAttribute('data-revoked')) + .toBe(JSON.stringify([oldURL])); + await page.getByRole('button', { name: 'Unmount resource' }).click(); + await expect(output).toHaveCount(0); + await expect + .poll(() => page.locator('#asset-hook').getAttribute('data-revoked')) + .toBe(JSON.stringify([oldURL, newURL])); +}); diff --git a/web/tests/unit/cloud-new-account-entry.test.mjs b/web/tests/unit/cloud-new-account-entry.test.mjs deleted file mode 100644 index 8a096be33..000000000 --- a/web/tests/unit/cloud-new-account-entry.test.mjs +++ /dev/null @@ -1,19 +0,0 @@ -import assert from 'node:assert/strict'; -import fs from 'node:fs'; -import test from 'node:test'; - -const source = fs.readFileSync( - new URL('../../src/app/login/page.tsx', import.meta.url), - 'utf8', -); - -test('normal Cloud login uses the standard Space OAuth callback path', () => { - assert.doesNotMatch(source, /cloudEntry/); - assert.match(source, /getSpaceAuthorizeUrl\(redirectUri\)/); -}); - -test('invitation login uses the same OAuth callback before accepting the invitation', () => { - assert.doesNotMatch(source, /cloudEntry/); - assert.match(source, /const invitationToken = getPendingInvitationToken\(\)/); - assert.match(source, /acceptWorkspaceInvitation\(invitationToken\)/); -}); diff --git a/web/tests/unit/oss-account-space-billing.test.mjs b/web/tests/unit/oss-account-space-billing.test.mjs index e49cf43cb..e5c683223 100644 --- a/web/tests/unit/oss-account-space-billing.test.mjs +++ b/web/tests/unit/oss-account-space-billing.test.mjs @@ -10,45 +10,6 @@ const root = path.resolve( ); const read = (file) => fs.readFileSync(path.join(root, file), 'utf8'); -test('invited local registration returns to login instead of authenticating', () => { - const source = read('src/app/invitations/accept/page.tsx'); - assert.doesNotMatch( - source, - /beginAuthenticatedSession\([\s\S]{0,120}response\.token/, - ); - assert.match(source, /navigate\('\/login\?invitation=1'/); -}); - -test('authenticated invitation page offers logout while retaining invitation', () => { - const source = read('src/app/invitations/accept/page.tsx'); - assert.match(source, /workspace\.logoutAndReturn/); - assert.match(source, /setPendingInvitationToken\(token\)/); -}); - -test('Space OAuth callback distinguishes unknown and unbound accounts by stable codes', () => { - const source = read('src/app/auth/space/callback/page.tsx'); - assert.match(source, /space_account_not_registered/); - assert.match(source, /space_account_binding_required/); -}); - -test('models panel derives LangBot Models billing state from workspace owner', () => { - const source = read('src/app/home/components/models-dialog/ModelsPanel.tsx'); - assert.match(source, /getWorkspaceSpaceBilling/); - assert.doesNotMatch(source, /getSpaceCredits\(\)/); - assert.match(source, /membership\.role === 'owner'/); -}); - -test('provider card represents owner and member owner-bound states explicitly', () => { - const source = read( - 'src/app/home/components/models-dialog/components/ProviderCard.tsx', - ); - assert.match(source, /isWorkspaceOwner/); - assert.match(source, /ownerSpaceBound/); - assert.match(source, /models\.ownerMustBindSpace/); - assert.match(source, /models\.usesOwnerSpaceBilling/); - assert.match(source, /isWorkspaceOwner && \(\s*