mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 09:07:40 +00:00
Load Jira attachment images separately from issue Markdown so descriptions and comments stay small while screenshots load lazily and open in the existing lightbox. Downloads begin when an image mounts, capture its owning host, require negotiated image support, and use bounded authenticated byte streams and caches. Older clients retain their inline-image handlers. Keep legacy and cancellable downloads in separate in-flight domains so cancelling a new image reader cannot abort a legacy reader; completed bytes share the bounded cache. Preserve the landed assignee, board, transition/comment queue and request lifetimes. Move unchanged project metadata IPC handlers into their domain module and tighten two inherited focus-check ceilings without changing behavior. Validation:410 tests/54 suites; all changed-source quality stages, lint, format, routing and contract checks; exact final node/web types; independent source review;48/48 fresh hidden Electron checks with226 stable source hashes and nine inspected screenshots. Synthetic transports exercise production clients and UI. Current CI static checks, mobile bundle, wire compatibility, Linux/Windows packaging and Windows serve pass; five test shards were pending at review. Extends the existing attachment work credited to Frank (@lifefloating) and Brennan Benson in PR #8938; existing contribution credits and media remain in this PR.
293 lines
13 KiB
JavaScript
293 lines
13 KiB
JavaScript
import { mkdtemp, readFile, rm } from 'node:fs/promises'
|
|
import { tmpdir } from 'node:os'
|
|
import { join } from 'node:path'
|
|
import { fileURLToPath } from 'node:url'
|
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
|
import { chromium } from 'playwright-core'
|
|
import { buildMobileWebAppBundle } from './build-mobile-web-app-bundle.mjs'
|
|
import { mobileWebAppDependenciesPresent } from './mobile-web-app-bundle-dependencies.mjs'
|
|
import {
|
|
createBundleServer,
|
|
installShellDouble,
|
|
readBridgeFaultGrant,
|
|
readBridgeProtocolVersion,
|
|
readShellCsp
|
|
} from './mobile-web-app-render-harness.mjs'
|
|
|
|
/**
|
|
* The two C4 page routes in a real browser, in the shape of the tasks and files checks.
|
|
*
|
|
* What only a browser answers for these: that every module in either closure imports and evaluates
|
|
* under React Native Web, that the route paints its own screen rather than the Unmatched route, and
|
|
* that each one's chunk arrives over the wire on a client-side navigation. The unit tests cannot say
|
|
* any of it, because they mock react-native away — it is Flow source vitest will not parse.
|
|
*
|
|
* The image policy is the other thing only a browser answers: that the header the shells send is
|
|
* the one this bundle is served under, and that neither route leaves the origin for anything while
|
|
* it paints.
|
|
*
|
|
* **The avatar skip is not proved here, and this file must not look as though it is.** `img-src` is
|
|
* `'self' data:` (rulings-ota-c4.md ruling 3), which is why `PRCommentCard` renders its empty-avatar
|
|
* `View` on web instead of letting one `<Image>` per comment attempt a fetch the policy refuses —
|
|
* but no comment card renders on either of these pages, because the PR chain the bottom of this file
|
|
* names is not scripted. An assertion here that no avatar host was requested passed with the
|
|
* platform check deleted, measured: 5 passed either way. It is gone rather than dressed up, and the
|
|
* only proof of that branch is
|
|
* `mobile/src/components/pr-sidebar/pr-comment-card-web-avatar.test.tsx`, which reds when the check
|
|
* is removed.
|
|
*
|
|
* What survives is a property of these two closures rather than of that component: nothing either
|
|
* route paints reaches off-origin, and nothing it paints violates the policy.
|
|
*
|
|
* What this file deliberately does not claim is at the bottom.
|
|
*/
|
|
|
|
const projectDir = fileURLToPath(new URL('../..', import.meta.url))
|
|
const HOST_ROUTE = '/h/render-check-host'
|
|
const WORKTREE = 'wt-1'
|
|
const HUB_ROUTE = `${HOST_ROUTE}/source-control/${WORKTREE}`
|
|
const REVIEW_ROUTE = `${HOST_ROUTE}/review/${WORKTREE}`
|
|
/** The patterns `init.pageRoutes` names, which is what the page matches a navigation against. */
|
|
const PAGE_ROUTE_PATTERNS = [HOST_ROUTE.replace('render-check-host', '[hostId]')].concat(
|
|
'/h/[hostId]/source-control/[worktreeId]',
|
|
'/h/[hostId]/review/[worktreeId]'
|
|
)
|
|
const SHELL_SESSION_ID = 'render-check-session'
|
|
const SHELL_BUILD_ID = 'render-check-build'
|
|
const SHELL_HOST = {
|
|
id: 'render-check-host',
|
|
name: 'Render Check Host',
|
|
endpoint: 'ws://render-check',
|
|
lastConnected: 1
|
|
}
|
|
const UNMATCHED = 'Unmatched Route'
|
|
const HUB_CHUNK_KEY = './h/[hostId]/source-control/[worktreeId].tsx'
|
|
const REVIEW_CHUNK_KEY = './h/[hostId]/review/[worktreeId].tsx'
|
|
/** Exactly what both routes declare in `MOBILE_WEB_PAGE_ROUTES`, plus the protocol's own grant. */
|
|
const C4_GRANTS = ['navigate', 'storage', 'externalLink', 'haptics', 'native.clipboard.write']
|
|
|
|
/**
|
|
* A GitHub remote, which is what puts the PR surfaces on screen at all.
|
|
*
|
|
* The only reply this file scripts. Everything below it — the PR itself, its comments, the review
|
|
* queue — is a refusal the screens have their own state for, and a double that answered them would
|
|
* be the place domain behaviour is decided rather than a transport.
|
|
*/
|
|
const REPLIES = { 'github.repoSlug': { owner: 'orca', repo: 'orca' } }
|
|
|
|
const bundles = mobileWebAppDependenciesPresent()
|
|
const describeRender = bundles ? describe : describe.skip
|
|
|
|
let scratch
|
|
let server
|
|
let browser
|
|
let origin
|
|
let routeChunks = {}
|
|
let cspHeader = null
|
|
let bridgeVersion = null
|
|
let faultGrant = null
|
|
|
|
beforeAll(async () => {
|
|
if (!bundles) {
|
|
return
|
|
}
|
|
cspHeader = await readShellCsp()
|
|
bridgeVersion = await readBridgeProtocolVersion()
|
|
faultGrant = await readBridgeFaultGrant()
|
|
scratch = await mkdtemp(join(tmpdir(), 'orca-mobile-web-app-source-control-'))
|
|
const built = await buildMobileWebAppBundle({ outDir: join(scratch, 'bundle') })
|
|
routeChunks = built.routeChunks
|
|
const served = await createBundleServer({ outDir: built.outDir, cspHeader })
|
|
server = served.server
|
|
origin = served.origin
|
|
const executablePath = process.env.ORCA_MOBILE_WEB_RENDER_BROWSER
|
|
browser = await chromium.launch({ headless: true, ...(executablePath ? { executablePath } : {}) })
|
|
}, 240_000)
|
|
|
|
afterAll(async () => {
|
|
await browser?.close()
|
|
server?.close()
|
|
if (scratch) {
|
|
await rm(scratch, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
/** A page carrying every signal these cases read: uncaught errors, console errors, request paths. */
|
|
async function openPage(route) {
|
|
const page = await browser.newPage({ viewport: { width: 390, height: 844 } })
|
|
// At document start, where the native shell installs the real channel: the entry reads it while
|
|
// its own script runs, so a channel added after `load` would already be too late.
|
|
await page.addInitScript(installShellDouble, {
|
|
version: bridgeVersion,
|
|
sessionId: SHELL_SESSION_ID,
|
|
buildId: SHELL_BUILD_ID,
|
|
route: { pathname: route },
|
|
host: SHELL_HOST,
|
|
storage: {},
|
|
faultGrant,
|
|
grants: [faultGrant, ...C4_GRANTS],
|
|
pageRoutes: PAGE_ROUTE_PATTERNS,
|
|
replies: REPLIES
|
|
})
|
|
const errors = []
|
|
const scripts = []
|
|
const requestedHosts = []
|
|
page.on('pageerror', (error) => errors.push(`${error.name}: ${error.message}`))
|
|
page.on('console', (message) => {
|
|
if (message.type() === 'error') {
|
|
errors.push(`console.error: ${message.text()}`)
|
|
}
|
|
})
|
|
// Every request, not only the ones that answered: a CSP refusal fails the request, and a check
|
|
// reading responses alone would read a blocked fetch as one that never happened.
|
|
page.on('request', (request) => requestedHosts.push(new URL(request.url()).host))
|
|
page.on('response', (response) => {
|
|
const path = new URL(response.url()).pathname
|
|
if (response.status() === 200 && path.endsWith('.js')) {
|
|
scripts.push(path)
|
|
}
|
|
})
|
|
return { page, errors, scripts, requestedHosts }
|
|
}
|
|
|
|
/**
|
|
* Wait for the entry to mount and then for the route's own content, polled rather than read once:
|
|
* every screen is deferred behind `import()`, so `mounted` lands while the chunk is still arriving.
|
|
*/
|
|
async function waitForRoute({ page, errors }, route, awaitText) {
|
|
const named = (what) =>
|
|
new Error(`${route} ${what}: ${errors.join(' | ') || 'no page or console error'}`)
|
|
try {
|
|
await page.waitForFunction(() => document.documentElement.dataset.orcaWebEntry === 'mounted', {
|
|
timeout: 60_000,
|
|
polling: 250
|
|
})
|
|
} catch {
|
|
throw named('never mounted')
|
|
}
|
|
try {
|
|
await page.waitForFunction((needle) => document.body.innerText.includes(needle), awaitText, {
|
|
timeout: 60_000,
|
|
polling: 250
|
|
})
|
|
} catch {
|
|
throw named(`mounted but never painted ${JSON.stringify(awaitText)}`)
|
|
}
|
|
// A route that threw under the page's own error boundary names itself here rather than timing
|
|
// out as a page that never mounted.
|
|
for (const fault of await page.evaluate(() => globalThis.__orcaRenderCheckFaults ?? [])) {
|
|
errors.push(`page fault: ${fault}`)
|
|
}
|
|
}
|
|
|
|
async function openRoute(route, awaitText) {
|
|
const opened = await openPage(route)
|
|
await opened.page.goto(`${origin}/`, { waitUntil: 'load' })
|
|
await waitForRoute(opened, route, awaitText)
|
|
return opened
|
|
}
|
|
|
|
describeRender(
|
|
'the source-control and review routes in a real browser',
|
|
() => {
|
|
it('mounts the source-control hub rather than the unmatched route', async () => {
|
|
const opened = await openRoute(HUB_ROUTE, 'Source Control')
|
|
const text = await opened.page.evaluate(() => document.body.innerText)
|
|
// The three segment chips, which is the hub and not a header that happens to say the words.
|
|
for (const chip of ['Changes', 'Pull Request', 'Commits']) {
|
|
expect(text).toContain(chip)
|
|
}
|
|
expect(text).not.toContain(UNMATCHED)
|
|
expect(opened.errors).toEqual([])
|
|
await opened.page.close()
|
|
}, 120_000)
|
|
|
|
it('mounts diff review rather than the unmatched route', async () => {
|
|
const opened = await openRoute(REVIEW_ROUTE, 'reviewed')
|
|
const text = await opened.page.evaluate(() => document.body.innerText)
|
|
// The queue filters, for the same reason the chips are read above.
|
|
for (const filter of ['All', 'Unreviewed', 'Notes']) {
|
|
expect(text).toContain(filter)
|
|
}
|
|
expect(text).not.toContain(UNMATCHED)
|
|
expect(opened.errors).toEqual([])
|
|
await opened.page.close()
|
|
}, 120_000)
|
|
|
|
it.each([
|
|
['source-control', HUB_ROUTE, 'Source Control', HUB_CHUNK_KEY],
|
|
['review', REVIEW_ROUTE, 'reviewed', REVIEW_CHUNK_KEY]
|
|
])(
|
|
"fetches %s's own chunk on a client-side navigation",
|
|
async (_name, route, text, key) => {
|
|
const opened = await openRoute(HOST_ROUTE, SHELL_HOST.name)
|
|
const loadedForFirstRoute = [...opened.scripts]
|
|
await opened.page.evaluate((to) => {
|
|
history.pushState(null, '', to)
|
|
dispatchEvent(new PopStateEvent('popstate'))
|
|
}, route)
|
|
await waitForRoute(opened, route, text)
|
|
const chunk = routeChunks[key]
|
|
expect(chunk, Object.keys(routeChunks).join(' ')).toBeTruthy()
|
|
// Named by the builder rather than guessed from the bytes: this is what says the route came
|
|
// over the wire now and not out of what the first route had already loaded.
|
|
expect(opened.scripts.filter((path) => !loadedForFirstRoute.includes(path))).toContain(
|
|
`/assets/${chunk}`
|
|
)
|
|
expect(loadedForFirstRoute).not.toContain(`/assets/${chunk}`)
|
|
await opened.page.close()
|
|
},
|
|
120_000
|
|
)
|
|
|
|
it('serves the shipped image policy and neither route violates it', async () => {
|
|
// The policy the shells send, read from the Kotlin source rather than restated, so this
|
|
// cannot pass against a header the app does not use.
|
|
expect(cspHeader).toContain("img-src 'self' data: blob: https:")
|
|
const swift = await readFile(
|
|
join(projectDir, 'mobile/modules/orca-mobile-web-shell/ios/MobileWebShellCsp.swift'),
|
|
'utf8'
|
|
)
|
|
// The other shell says the same thing, which no served header can show.
|
|
expect(swift).toContain(`"img-src 'self' data: blob: https:"`)
|
|
|
|
for (const [route, text] of [
|
|
[HUB_ROUTE, 'Source Control'],
|
|
[REVIEW_ROUTE, 'reviewed']
|
|
]) {
|
|
const opened = await openRoute(route, text)
|
|
// Chromium reports a refused subresource as a console error naming the directive, so
|
|
// anything either closure loaded that the policy blocked lands here.
|
|
expect(opened.errors.filter((entry) => entry.includes('Content Security Policy'))).toEqual(
|
|
[]
|
|
)
|
|
// Stronger than the line above and independent of it: not one request left the origin, so
|
|
// there is nothing for the policy to have refused. A font, a beacon or a provider image
|
|
// added anywhere in either closure reds this. Since the directive admits `https:`, an empty
|
|
// list is these two closures fetching nothing rather than the policy refusing something:
|
|
// the avatar that would fetch needs provider data this page never gets, as below.
|
|
expect(opened.requestedHosts.filter((host) => host !== new URL(origin).host)).toEqual([])
|
|
await opened.page.close()
|
|
}
|
|
}, 180_000)
|
|
},
|
|
600_000
|
|
)
|
|
|
|
/**
|
|
* What this file does not claim, and where each is answered instead.
|
|
*
|
|
* **The PR surfaces below the trigger.** `MobilePRSidebar`, its comment cards and the `RightDrawer`
|
|
* they sit in need `github.prForBranch` and `github.prComments` to answer, and the review queue
|
|
* needs `worktree.show`, `repo.list` and `repo.baseRefDefault` on top of `git.status` — measured on
|
|
* this tree by driving the page with the double. Scripting that chain would put five hand-written
|
|
* fixtures against five Zod schemas into a transport double, which is the thing the harness's own
|
|
* docstring says it must not become. So neither the comment avatar nor the drawer is exercised on
|
|
* this page at all: the avatar's branch is the component test named in the header. The drawer's
|
|
* scroll behavior belongs with the device proof, which reaches a real PR.
|
|
*
|
|
* **The seams.** The external-link, clipboard and router seams this series moved are each reached
|
|
* from a control that only renders once the screens have provider data, for the same reason. They
|
|
* are pinned by the source censuses beside them and by the host and port-pair suites.
|
|
*/
|