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
1 change: 1 addition & 0 deletions apps/conciv/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
"zod": "^4.4.3"
},
"devDependencies": {
"@conciv/browser-fixture": "workspace:*",
"@conciv/extension-page": "workspace:^",
"@conciv/extension-terminal": "workspace:^",
"@conciv/extension-testkit": "workspace:^",
Expand Down
116 changes: 69 additions & 47 deletions apps/conciv/test/transport-standalone.it.test.ts
Original file line number Diff line number Diff line change
@@ -1,41 +1,55 @@
import {afterAll, beforeAll, describe, expect, it} from 'vitest'
import {expect} from 'vitest'
import {expect as expectLocator} from 'playwright/test'
import {chromium, type Browser, type Page} from 'playwright'
import type {Browser, Page} from 'playwright'
import {test as browserTest} from '@conciv/browser-fixture'
import {bootCoreKit, type CoreKit} from '@conciv/extension-testkit/core-kit'
import {httpRpcRequestUrls, observeRpc, type RpcObserver} from '@conciv/extension-testkit/rpc-observer'
import {serveStandaloneApp} from './helpers/static-app.js'
import {proxyTo, type ProxyCore} from './helpers/proxy.js'

const ASSISTANT_TEXT = 'Hello from standalone conciv'
const MOUNT_TIMEOUT_MS = 30_000
const SUITE_SETUP_TIMEOUT_MS = 90_000

let browser: Browser
let kit: CoreKit
let openCore: ProxyCore
let app: {base: string; close: () => Promise<void>}

beforeAll(async () => {
browser = await chromium.launch()
kit = await bootCoreKit({id: 'standalone-transport', text: ASSISTANT_TEXT})
openCore = await proxyTo(kit.base)
app = await serveStandaloneApp()
}, 90_000)

afterAll(async () => {
await browser.close()
await app.close()
await openCore.close()
await kit.cleanup()
const test = browserTest.extend<{
$file: {kit: CoreKit; openCore: ProxyCore; app: {base: string; close: () => Promise<void>}}
}>({
kit: [
// oxlint-disable-next-line no-empty-pattern -- vitest's fixture parser requires the literal `{}` destructuring
async ({}, use) => {
const kit = await bootCoreKit({id: 'standalone-transport', text: ASSISTANT_TEXT})
await use(kit)
await kit.cleanup()
},
{scope: 'file'},
],
openCore: [
async ({kit}, use) => {
const openCore = await proxyTo(kit.base)
await use(openCore)
await openCore.close()
},
{scope: 'file'},
],
app: [
// oxlint-disable-next-line no-empty-pattern -- vitest's fixture parser requires the literal `{}` destructuring
async ({}, use) => {
const app = await serveStandaloneApp()
await use(app)
await app.close()
},
{scope: 'file'},
],
})

function pageUrl(coreBase: string, transport: 'websocket' | 'fetch'): string {
function pageUrl(appBase: string, coreBase: string, transport: 'websocket' | 'fetch'): string {
const settings = encodeURIComponent(JSON.stringify({transport}))
return `${app.base}/?core=${encodeURIComponent(coreBase)}&settings=${settings}`
return `${appBase}/?core=${encodeURIComponent(coreBase)}&settings=${settings}`
}

type Tab = {page: Page; observer: RpcObserver; httpRpcUrls: string[]; disposeHttpRpc: () => void}

async function openTab(url: string): Promise<Tab> {
async function openTab(browser: Browser, url: string): Promise<Tab> {
const page = await browser.newPage()
const http = httpRpcRequestUrls(page)
const observer = observeRpc(page)
Expand All @@ -51,30 +65,38 @@ async function completeTurn(page: Page): Promise<void> {
await expectLocator(page.getByText(ASSISTANT_TEXT).first()).toBeVisible({timeout: MOUNT_TIMEOUT_MS})
}

describe('the standalone entry threads settings.transport into the browser rpc client', () => {
it('pins fetch and never opens a websocket when settings say transport: fetch', async () => {
const tab = await openTab(pageUrl(openCore.base, 'fetch'))
try {
await completeTurn(tab.page)
expect(tab.observer.socketCount()).toBe(0)
expect(tab.httpRpcUrls.length).toBeGreaterThan(0)
} finally {
tab.observer.dispose()
tab.disposeHttpRpc()
await tab.page.close()
}
})
test.describe('the standalone entry threads settings.transport into the browser rpc client', () => {
test(
'pins fetch and never opens a websocket when settings say transport: fetch',
async ({browser, app, openCore}) => {
const tab = await openTab(browser, pageUrl(app.base, openCore.base, 'fetch'))
try {
await completeTurn(tab.page)
expect(tab.observer.socketCount()).toBe(0)
expect(tab.httpRpcUrls.length).toBeGreaterThan(0)
} finally {
tab.observer.dispose()
tab.disposeHttpRpc()
await tab.page.close()
}
},
SUITE_SETUP_TIMEOUT_MS,
)

it('pins the websocket and never falls back to fetch when settings say transport: websocket', async () => {
const tab = await openTab(pageUrl(openCore.base, 'websocket'))
try {
await completeTurn(tab.page)
expect(tab.observer.socketCount()).toBe(1)
expect(tab.httpRpcUrls).toEqual([])
} finally {
tab.observer.dispose()
tab.disposeHttpRpc()
await tab.page.close()
}
})
test(
'pins the websocket and never falls back to fetch when settings say transport: websocket',
async ({browser, app, openCore}) => {
const tab = await openTab(browser, pageUrl(app.base, openCore.base, 'websocket'))
try {
await completeTurn(tab.page)
expect(tab.observer.socketCount()).toBe(1)
expect(tab.httpRpcUrls).toEqual([])
} finally {
tab.observer.dispose()
tab.disposeHttpRpc()
await tab.page.close()
}
},
SUITE_SETUP_TIMEOUT_MS,
)
})
2 changes: 2 additions & 0 deletions apps/conciv/vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ export default defineConfig({
environment: 'node',
include: ['test/**/*.test.ts'],
exclude: ['test/**/*.browser.test.ts', 'test/**/*.browser.test.tsx'],
testTimeout: ciTest().testTimeout,
hookTimeout: ciTest().hookTimeout,
},
},
{
Expand Down
1 change: 1 addition & 0 deletions apps/site/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@
},
"devDependencies": {
"@cloudflare/vite-plugin": "^1.42.4",
"@conciv/browser-fixture": "workspace:*",
"@conciv/core": "workspace:*",
"@conciv/extension-compiler": "workspace:*",
"@conciv/harness-testkit": "workspace:*",
Expand Down
23 changes: 8 additions & 15 deletions apps/site/test/live-connect.it.test.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,24 @@
import {afterAll, beforeAll, describe, expect, it} from 'vitest'
import {afterAll, expect} from 'vitest'
import {expect as expectLocator} from 'playwright/test'
import {chromium, type Browser} from 'playwright'
import {createFakeHarness} from '@conciv/harness-testkit'
import {runConnect} from '@conciv/try'
import type {Engine} from '@conciv/core/start'
import {startWranglerDev, type WranglerDev} from './wrangler-dev'
import {createSiteTest} from './site-fixture.js'

const SITE_PORT = 8787
const INSPECTOR_PORT = 9787
const ORIGIN = `http://127.0.0.1:${SITE_PORT}`
let site: WranglerDev
let browser: Browser
let engine: Engine | null = null

beforeAll(async () => {
site = await startWranglerDev({port: SITE_PORT, inspectorPort: INSPECTOR_PORT})
browser = await chromium.launch()
}, 120_000)
const test = createSiteTest({port: SITE_PORT, inspectorPort: INSPECTOR_PORT})

let engine: Engine | null = null

afterAll(async () => {
await browser?.close()
await engine?.stop()
await site?.stop()
})

describe('widget-native live connect on the built site', () => {
it('boots the widget into connect steps and hands off in place to live chat', async () => {
test.describe('widget-native live connect on the built site', () => {
test('boots the widget into connect steps and hands off in place to live chat', async ({browser}) => {
const page = await browser.newPage()
await page.goto(ORIGIN, {waitUntil: 'domcontentloaded'})
const panel = page.getByRole('dialog', {name: 'conciv chat agent'})
Expand Down Expand Up @@ -70,7 +63,7 @@ describe('widget-native live connect on the built site', () => {
engine = null
}, 180_000)

it('remembers a pre-connect dismissal, and ?try=1 forces the panel open again', async () => {
test('remembers a pre-connect dismissal, and ?try=1 forces the panel open again', async ({browser}) => {
const page = await browser.newPage()
await page.goto(ORIGIN, {waitUntil: 'domcontentloaded'})
const panel = page.getByRole('dialog', {name: 'conciv chat agent'})
Expand Down
38 changes: 14 additions & 24 deletions apps/site/test/mobile-gating.it.test.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,16 @@
import {afterAll, beforeAll, describe, expect, it} from 'vitest'
import {expect} from 'vitest'
import {expect as expectLocator} from 'playwright/test'
import {chromium, devices, type Browser} from 'playwright'
import {startWranglerDev, type WranglerDev} from './wrangler-dev'
import {devices} from 'playwright'
import {createSiteTest} from './site-fixture.js'

const SITE_PORT = 8788
const INSPECTOR_PORT = 9788
const ORIGIN = `http://127.0.0.1:${SITE_PORT}`
let site: WranglerDev
let browser: Browser

beforeAll(async () => {
site = await startWranglerDev({port: SITE_PORT, inspectorPort: INSPECTOR_PORT})
browser = await chromium.launch()
}, 120_000)
const test = createSiteTest({port: SITE_PORT, inspectorPort: INSPECTOR_PORT})

afterAll(async () => {
await browser?.close()
await site?.stop()
})

describe('landing gates the dev-only demo behind a non-mobile pointer', () => {
it('mounts the live widget and shows the install + try-it CTAs on desktop', async () => {
test.describe('landing gates the dev-only demo behind a non-mobile pointer', () => {
test('mounts the live widget and shows the install + try-it CTAs on desktop', async ({browser}) => {
const page = await browser.newPage()
await page.goto(ORIGIN, {waitUntil: 'domcontentloaded'})

Expand All @@ -31,7 +21,7 @@ describe('landing gates the dev-only demo behind a non-mobile pointer', () => {
await page.close()
}, 60_000)

it('does not mount the live widget or the CTAs on a mobile device', async () => {
test('does not mount the live widget or the CTAs on a mobile device', async ({browser}) => {
const context = await browser.newContext(devices['iPhone 13'])
const page = await context.newPage()
await page.goto(ORIGIN, {waitUntil: 'domcontentloaded'})
Expand All @@ -44,8 +34,8 @@ describe('landing gates the dev-only demo behind a non-mobile pointer', () => {
}, 60_000)
})

describe('the live widget mounts site-wide and the root widget param decides the panel', () => {
it('shows the launcher with the panel closed on a docs page on desktop', async () => {
test.describe('the live widget mounts site-wide and the root widget param decides the panel', () => {
test('shows the launcher with the panel closed on a docs page on desktop', async ({browser}) => {
const page = await browser.newPage()
await page.goto(`${ORIGIN}/docs/quick-start`, {waitUntil: 'domcontentloaded'})

Expand All @@ -55,7 +45,7 @@ describe('the live widget mounts site-wide and the root widget param decides the
await page.close()
}, 60_000)

it('auto-opens the panel on the home page without a widget param in the URL', async () => {
test('auto-opens the panel on the home page without a widget param in the URL', async ({browser}) => {
const page = await browser.newPage()
await page.goto(ORIGIN, {waitUntil: 'domcontentloaded'})

Expand All @@ -65,7 +55,7 @@ describe('the live widget mounts site-wide and the root widget param decides the
await page.close()
}, 60_000)

it('keeps the panel closed on the home page when ?widget=false is explicit', async () => {
test('keeps the panel closed on the home page when ?widget=false is explicit', async ({browser}) => {
const page = await browser.newPage()
await page.goto(`${ORIGIN}/?widget=false`, {waitUntil: 'domcontentloaded'})

Expand All @@ -75,7 +65,7 @@ describe('the live widget mounts site-wide and the root widget param decides the
await page.close()
}, 60_000)

it('keeps the open panel mounted while navigating from the landing page to the docs', async () => {
test('keeps the open panel mounted while navigating from the landing page to the docs', async ({browser}) => {
const page = await browser.newPage()
await page.goto(ORIGIN, {waitUntil: 'domcontentloaded'})

Expand All @@ -90,7 +80,7 @@ describe('the live widget mounts site-wide and the root widget param decides the
await page.close()
}, 60_000)

it('keeps a closed panel closed across navigation to the docs and back to the landing page', async () => {
test('keeps a closed panel closed across navigation to the docs and back to the landing page', async ({browser}) => {
const page = await browser.newPage()
await page.goto(ORIGIN, {waitUntil: 'domcontentloaded'})

Expand All @@ -111,7 +101,7 @@ describe('the live widget mounts site-wide and the root widget param decides the
await page.close()
}, 60_000)

it('opens the panel on a docs page when ?widget=true is explicit', async () => {
test('opens the panel on a docs page when ?widget=true is explicit', async ({browser}) => {
const page = await browser.newPage()
await page.goto(`${ORIGIN}/docs/quick-start?widget=true`, {waitUntil: 'domcontentloaded'})

Expand Down
16 changes: 16 additions & 0 deletions apps/site/test/site-fixture.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import {test as browserTest} from '@conciv/browser-fixture'
import {startWranglerDev, type WranglerDev} from './wrangler-dev.js'

export function createSiteTest(options: {port: number; inspectorPort: number}) {
return browserTest.extend<{$file: {site: WranglerDev}}>({
site: [
// oxlint-disable-next-line no-empty-pattern -- vitest's fixture parser requires the literal `{}` destructuring
async ({}, use) => {
const site = await startWranglerDev(options)
await use(site)
await site.stop()
},
{scope: 'file', auto: true},
],
})
}
30 changes: 30 additions & 0 deletions packages/browser-fixture/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
{
"name": "@conciv/browser-fixture",
"version": "0.0.18",
"private": true,
"description": "Internal test infra: a vitest test.extend() fixture that boots one playwright chromium instance per test file, with a p-timeout-bounded close. Zero @conciv/* dependencies by design, so any package can use it without risking a workspace dependency cycle.",
"homepage": "https://conciv.dev",
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/conciv-dev/conciv.git",
"directory": "packages/browser-fixture"
},
"type": "module",
"exports": {
".": "./src/browser-fixture.ts"
},
"scripts": {
"typecheck": "tsc -p tsconfig.json --noEmit",
"lint": "oxlint"
},
"dependencies": {
"p-timeout": "^7.0.1",
"playwright": "^1.61.1",
"vitest": "^4.1.8"
},
"devDependencies": {
"@types/node": "^22.19.21",
"typescript": "^6.0.3"
}
}
20 changes: 20 additions & 0 deletions packages/browser-fixture/src/browser-fixture.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import {test as base} from 'vitest'
import {chromium, type Browser} from 'playwright'
import pTimeout from 'p-timeout'

const BROWSER_CLOSE_TIMEOUT_MS = 30_000

export const test = base.extend<{$file: {browser: Browser}}>({
browser: [
// oxlint-disable-next-line no-empty-pattern -- vitest's fixture parser requires the literal `{}` destructuring
async ({}, use) => {
const browser = await chromium.launch()
await use(browser)
await pTimeout(browser.close(), {
milliseconds: BROWSER_CLOSE_TIMEOUT_MS,
message: `browser.close did not settle within ${BROWSER_CLOSE_TIMEOUT_MS}ms; a wedged CDP connection would otherwise hang fixture cleanup forever (vitest test.extend cleanup is unbounded)`,
})
Comment on lines +13 to +16
},
{scope: 'file'},
],
})
11 changes: 11 additions & 0 deletions packages/browser-fixture/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"incremental": true,
"tsBuildInfoFile": ".tsbuildinfo",
"rootDir": ".",
"noEmit": true,
"types": ["node"]
},
"include": ["src/**/*.ts"]
}
Loading
Loading