Skip to content
Closed
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
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ account_password=<password>
# Optional — keep the default if your machine has enough RAM.
NODE_OPTIONS=--max-old-space-size=2048

# Public GitHub OAuth/BFF deployment origin (no trailing slash).
# Use the organization-owned Deno project; never fall back to a personal deployment.
TRONIDE_GITHUB_BFF_ORIGIN=https://tronide-gh-oauth.<DENO_ORG_SLUG>.deno.net

# Public TronGrid API key inlined into the IDE bundle at build time.
# Optional. When unset, the IDE falls back to anonymous TronGrid rate limits.
# This value is readable by every browser user of the built app, so only use a
Expand Down
18 changes: 18 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,24 @@ concurrency:
cancel-in-progress: true

jobs:
github-oauth-bff:
name: GitHub OAuth BFF checks
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7

- name: Setup Deno
uses: denoland/setup-deno@e95548e56dfa95d4e1a28d6f422fafe75c4c26fb # v2.0.3
with:
deno-version: v2.4.2

- name: Check and test BFF
working-directory: services/github-oauth
run: |
deno task check
deno task test

lint:
name: Lint
runs-on: ubuntu-latest
Expand Down
17 changes: 17 additions & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -57,11 +57,28 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile

- name: Validate GitHub BFF configuration
env:
TRONIDE_GITHUB_BFF_ORIGIN: ${{ vars.TRONIDE_GITHUB_BFF_ORIGIN }}
run: |
if [ -z "$TRONIDE_GITHUB_BFF_ORIGIN" ]; then
echo "::error::Set the TRONIDE_GITHUB_BFF_ORIGIN repository variable before deploying."
exit 1
fi
case "$TRONIDE_GITHUB_BFF_ORIGIN" in
https://*) ;;
*)
echo "::error::TRONIDE_GITHUB_BFF_ORIGIN must be an HTTPS origin."
exit 1
;;
esac

# build.sh runs the production nx build (baseHref "./", path-agnostic) and
# normalises index.html. Output: build/apps/remix-ide.
- name: Build
env:
NODE_OPTIONS: --max-old-space-size=6144
TRONIDE_GITHUB_BFF_ORIGIN: ${{ vars.TRONIDE_GITHUB_BFF_ORIGIN }}
run: bash build.sh

# Pages serves a CNAME file as the custom domain. A push to main always
Expand Down
34 changes: 20 additions & 14 deletions apps/remix-ide-pw/tests/git-remote.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { test, expect, Page } from '@playwright/test'
import { dismissWelcomeModal } from './helpers'
import { dismissWelcomeModal, seedGithubBffSession } from './helpers'

// TC-GIT-R1/R2/R3 (v2.3.2 remote-git): the Git panel exposes Clone (into a new
// workspace) + Add-remote + Push/Pull against a GitHub remote, routed through
Expand All @@ -22,14 +22,10 @@ async function openGitPanel (page: Page) {
await page.locator('[data-id="gitPanel"]').waitFor({ state: 'visible', timeout: 15_000 })
}

async function connectFakeGithubToken (page: Page) {
async function connectFakeGithubSession (page: Page) {
await seedGithubBffSession(page, 'force-push-tester')
const advanced = page.locator('[data-id="landingAdvancedToolsToggle"]')
if ((await advanced.getAttribute('aria-expanded')) === 'false') await advanced.click()
await page.locator('[data-id="landingGithubTokenConnect"]').click()
const tokenInput = page.locator('[data-id="modalDialogCustomPromptText"]')
await tokenInput.waitFor({ state: 'visible', timeout: 10_000 })
await tokenInput.fill('ghp_force_push_confirmation_test')
await page.locator('#modal-footer-ok').click()
await expect(page.locator('[data-id="landingGithubTokenDisconnect"]')).toBeVisible({ timeout: 10_000 })
}

Expand Down Expand Up @@ -73,14 +69,8 @@ test.describe('Git panel (remote)', () => {
})

test('TC-GIT-R7: force push requires confirmation; cancel blocks it and normal push remains direct', { tag: '@gate' }, async ({ page }) => {
await page.route('https://api.github.com/user', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ login: 'force-push-tester' })
}))
await openHome(page)
await connectFakeGithubToken(page)
await connectFakeGithubSession(page)
await openGitPanel(page)

const init = page.locator('[data-id="gitInit"]')
Expand All @@ -100,8 +90,21 @@ test.describe('Git panel (remote)', () => {
}

let proxiedPushes = 0
const proxiedHeaders: Array<Record<string, string>> = []
await page.route('**/git/**', (route) => {
const request = route.request()
if (request.method() === 'OPTIONS') {
return route.fulfill({
status: 204,
headers: {
'access-control-allow-origin': request.headers().origin || new URL(page.url()).origin,
'access-control-allow-methods': 'GET, POST, OPTIONS',
'access-control-allow-headers': request.headers()['access-control-request-headers'] || 'x-tronide-session'
}
})
}
proxiedPushes++
proxiedHeaders.push(request.headers())
return route.abort()
})
await page.locator('[data-id="gitAddRemoteUrl"]').fill('https://github.com/octocat/Hello-World.git')
Expand All @@ -110,6 +113,7 @@ test.describe('Git panel (remote)', () => {
// Add remote now performs an all-ref fetch. This test intentionally aborts
// that request to remain an offline @gate test; count only later pushes.
proxiedPushes = 0
proxiedHeaders.length = 0

// Approval is scoped to the branch that was visible in the modal. Switch
// branches programmatically while it is open; confirming the stale modal
Expand Down Expand Up @@ -143,6 +147,8 @@ test.describe('Git panel (remote)', () => {
await page.locator('[data-id="gitForcePush"]').click()
await page.locator('#modal-footer-ok').click()
await expect.poll(() => proxiedPushes, { timeout: 15_000 }).toBeGreaterThan(0)
expect(proxiedHeaders.every((headers) => headers['x-tronide-session'] === 'test_bff_session_handle_012345678901234567890')).toBe(true)
expect(proxiedHeaders.every((headers) => !headers.authorization)).toBe(true)
await expect(page.locator('[data-id="gitStatus"]')).toContainText(/push failed/i, { timeout: 15_000 })

// Ordinary Push remains direct: no destructive-action modal is introduced.
Expand Down
40 changes: 18 additions & 22 deletions apps/remix-ide-pw/tests/github-header-menu.spec.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,21 @@
import { test, expect, Page } from '@playwright/test'
import { gotoHome } from './helpers'
import { gotoHome, seedGithubBffSession } from './helpers'

// TC-GITHUB-003 (v2.3.2): once GitHub is connected, the HEADER GitHub button
// must open an account menu (Reconnect / Disconnect) instead of re-running the
// OAuth popup. Re-triggering OAuth while already authorized makes GitHub flash a
// popup open and immediately closed (it redirects straight back) — confusing and
// pointless. Connect is done via the PAT path with a mocked /user so the test is
// deterministic (no real OAuth, no network, no compile) → @gate.
// pointless. Connected state is seeded with an opaque BFF session so the test is
// deterministic (no real OAuth, GitHub token, network, or compile) → @gate.

async function connectViaPat (page: Page) {
const advToggle = page.locator('[data-id="landingAdvancedToolsToggle"]')
if ((await advToggle.getAttribute('aria-expanded')) === 'false') await advToggle.click()
await expect(page.locator('[data-id="landingGithubTokenPanel"]')).toBeVisible({ timeout: 10_000 })
await page.locator('[data-id="landingGithubTokenConnect"]').click()
const tokenInput = page.locator('[data-id="modalDialogCustomPromptText"]')
await tokenInput.waitFor({ state: 'visible', timeout: 10_000 })
await tokenInput.fill('ghp_faketoken_for_test')
await page.locator('#modal-footer-ok').click()
async function connectViaBffSession (page: Page) {
await seedGithubBffSession(page)
}

test.describe('Header GitHub menu', () => {
test('TC-GITHUB-003: a connected GitHub button opens a menu, never a re-auth popup', { tag: '@gate' }, async ({ page, context }) => {
await page.route('https://api.github.com/user', (route) =>
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ login: 'tron-tester' }) }))

await gotoHome(page)
await connectViaPat(page)
await connectViaBffSession(page)

const headerBtn = page.locator('[data-id="headerGithubConnect"]')
await expect(headerBtn).toContainText('tron-tester', { timeout: 10_000 })
Expand All @@ -51,10 +41,8 @@ test.describe('Header GitHub menu', () => {
// TC-GITHUB-005 (v2.3.2): Escape closes the header account menu. It's a
// lightweight popover — outside-click alone left keyboard users stuck.
test('TC-GITHUB-005: Escape closes the header GitHub menu', { tag: '@gate' }, async ({ page }) => {
await page.route('https://api.github.com/user', (route) =>
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ login: 'tron-tester' }) }))
await gotoHome(page)
await connectViaPat(page)
await connectViaBffSession(page)
const headerBtn = page.locator('[data-id="headerGithubConnect"]')
await expect(headerBtn).toContainText('tron-tester', { timeout: 10_000 })

Expand All @@ -69,10 +57,10 @@ test.describe('Header GitHub menu', () => {
// empty token store because only its OWN disconnect path re-rendered the
// panel; it now listens to tronideGithubConnectionChanged.
test('TC-GITHUB-006: header Disconnect refreshes the Home panel button label', { tag: '@gate' }, async ({ page }) => {
await page.route('https://api.github.com/user', (route) =>
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ login: 'tron-tester' }) }))
await gotoHome(page)
await connectViaPat(page)
await connectViaBffSession(page)
const advanced = page.locator('[data-id="landingAdvancedToolsToggle"]')
if ((await advanced.getAttribute('aria-expanded')) === 'false') await advanced.click()
const headerBtn = page.locator('[data-id="headerGithubConnect"]')
await expect(headerBtn).toContainText('tron-tester', { timeout: 10_000 })
// connected state reflected on the Home button
Expand All @@ -89,12 +77,20 @@ test.describe('Header GitHub menu', () => {
// When NOT connected, the button must keep its original behavior: route to
// the Home GitHub panel and start the connect flow (no menu).
await gotoHome(page)
let capabilityChecks = 0
await page.route('**/capabilities', (route) => {
capabilityChecks++
return route.fulfill({ status: 404, body: 'legacy proxy' })
})
const headerBtn = page.locator('[data-id="headerGithubConnect"]')
await expect(headerBtn).toContainText('Connect GitHub')
await headerBtn.click()
// No account menu in the disconnected state…
await expect(page.locator('[data-id="headerGithubMenu"]')).toHaveCount(0)
// …and the Home GitHub token panel is brought into view (connect entry point).
await expect(page.locator('[data-id="landingGithubTokenPanel"]')).toBeVisible({ timeout: 10_000 })
await expect.poll(() => capabilityChecks).toBe(1)
await expect(page.locator('[data-id="landingGithubOAuthConnect"]')).toHaveText('Connect to GitHub')
expect(await page.evaluate(() => window.sessionStorage.getItem('tronide.github.session'))).toBeNull()
})
})
34 changes: 13 additions & 21 deletions apps/remix-ide-pw/tests/github-token-modal.spec.ts
Original file line number Diff line number Diff line change
@@ -1,32 +1,24 @@
import { test, expect } from '@playwright/test'
import { dismissWelcomeModal } from './helpers'

test.describe('GitHub token modal (tab-session storage)', () => {
test('Connect token modal explains refresh-safe tab-only storage', async ({ page }) => {
test.describe('GitHub BFF credential boundary', () => {
test('GitHub panel exposes OAuth only and never a browser PAT input', async ({ page }) => {
await page.goto('/')
await dismissWelcomeModal(page)

// Toggle Advanced Tools open to make GitHub Token panel visible
await page.locator('[data-id="landingAdvancedToolsToggle"]').click()
const panel = page.locator('[data-id="landingGithubTokenPanel"]')
await panel.waitFor({ timeout: 30_000 })

await page.locator('[data-id="landingGithubTokenPanel"]').waitFor({ timeout: 30_000 })
await expect(panel).toContainText('never receives or stores the GitHub access token')
await expect(page.locator('[data-id="landingGithubTokenConnect"]')).toHaveCount(0)
await expect(page.locator('text=Connect token (PAT)')).toHaveCount(0)
await expect(page.locator('[data-id="landingGithubOAuthConnect"]')).toHaveText('Connect to GitHub')

// Sanity-check the pre-modal state: sessionStorage is empty and no legacy
// localStorage tokens survive a fresh load.
const storageBefore = await page.evaluate(() => ({
session: window.sessionStorage.getItem('tronide.github.token'),
local: window.localStorage.getItem('tronide.github.token')
const storage = await page.evaluate(() => ({
session: window.sessionStorage.getItem('tronide.github.session'),
token: window.sessionStorage.getItem('tronide.github.token'),
localToken: window.localStorage.getItem('tronide.github.token')
}))
expect(storageBefore.session).toBeNull()
expect(storageBefore.local).toBeNull()

await page.locator('[data-id="landingGithubTokenConnect"]').click()

// The connection is automatic for this tab; there is no persistent-storage
// checkbox that could accidentally promote it to localStorage.
await expect(page.locator('text=Tokens stay in this browser tab')).toBeVisible({ timeout: 5_000 })
await expect(page.locator('text=survive a refresh')).toBeVisible({ timeout: 5_000 })
await expect(page.locator('#githubTokenRemember')).toHaveCount(0)
await expect(page.locator('text=Remember in this browser')).toHaveCount(0)
expect(storage).toEqual({ session: null, token: null, localToken: null })
})
})
49 changes: 16 additions & 33 deletions apps/remix-ide-pw/tests/github-token-session.spec.ts
Original file line number Diff line number Diff line change
@@ -1,51 +1,34 @@
import { test, expect, Page } from '@playwright/test'
import { gotoHome } from './helpers'

// TC-GITHUB-002 (v2.3.2 tab-session token): a refresh must keep the GitHub
// connection in the same tab without creating a persistent localStorage/config
// copy. The /user lookup is mocked so no real token or network is needed → @gate.
import { gotoHome, seedGithubBffSession } from './helpers'

async function expandAdvancedTools (page: Page) {
const advToggle = page.locator('[data-id="landingAdvancedToolsToggle"]')
if ((await advToggle.getAttribute('aria-expanded')) === 'false') await advToggle.click()
const advanced = page.locator('[data-id="landingAdvancedToolsToggle"]')
if ((await advanced.getAttribute('aria-expanded')) === 'false') await advanced.click()
await expect(page.locator('[data-id="landingGithubTokenPanel"]')).toBeVisible({ timeout: 10_000 })
}

test.describe('GitHub token survives refresh in this tab', () => {
test('TC-GITHUB-002: a reload keeps the tab-session token and connected UI', { tag: '@gate' }, async ({ page }) => {
await page.route('https://api.github.com/user', (route) =>
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ login: 'tron-tester' }) }))

test.describe('GitHub BFF session survives refresh in this tab', () => {
test('TC-GITHUB-002: reload keeps only the opaque session and connected UI', { tag: '@gate' }, async ({ page }) => {
await page.route('**/session', (route) => route.fulfill({ status: 204 }))
await gotoHome(page)
await seedGithubBffSession(page)
await expandAdvancedTools(page)
const panel = page.locator('[data-id="landingGithubTokenPanel"]')

const connectBtn = page.locator('[data-id="landingGithubTokenConnect"]')
await expect(connectBtn).toHaveText('Connect token (PAT)')

// Connect with a (mock-validated) token.
await connectBtn.click()
const tokenInput = page.locator('[data-id="modalDialogCustomPromptText"]')
await tokenInput.waitFor({ state: 'visible', timeout: 10_000 })
await tokenInput.fill('ghp_faketoken_for_test')
await page.locator('#modal-footer-ok').click()

// Connected in the UI and mirrored only to this tab's session storage.
await expect(page.locator('[data-id="landingGithubTokenDisconnect"]')).toBeVisible({ timeout: 10_000 })
await expect(panel).toContainText('tron-tester')
expect(await page.evaluate(() => window.sessionStorage.getItem('tronide.github.token'))).toBe('ghp_faketoken_for_test')
const connect = page.locator('[data-id="landingGithubOAuthConnect"]')
await expect(connect).toHaveText('Reconnect GitHub')
await expect(page.locator('[data-id="landingGithubTokenDisconnect"]')).toBeVisible()
await expect(page.locator('[data-id="landingGithubTokenPanel"]')).toContainText('tron-tester')
expect(await page.evaluate(() => window.sessionStorage.getItem('tronide.github.session'))).toContain('test_bff_session')
expect(await page.evaluate(() => window.sessionStorage.getItem('tronide.github.token'))).toBeNull()
expect(await page.evaluate(() => window.localStorage.getItem('tronide.github.token'))).toBeNull()

// Full re-navigation in the same tab rehydrates the token and login.
await gotoHome(page)
await expandAdvancedTools(page)
await expect(page.locator('[data-id="landingGithubTokenConnect"]')).toHaveText('Reconnect token', { timeout: 10_000 })
await expect(page.locator('[data-id="landingGithubTokenDisconnect"]')).toBeVisible()
await expect(panel).toContainText('tron-tester')
await expect(connect).toHaveText('Reconnect GitHub')

// Disconnect clears the session mirror as well as the live state.
await page.locator('[data-id="landingGithubTokenDisconnect"]').click()
await expect(page.locator('[data-id="landingGithubTokenConnect"]')).toHaveText('Connect token (PAT)')
await expect(connect).toHaveText('Connect to GitHub')
expect(await page.evaluate(() => window.sessionStorage.getItem('tronide.github.session'))).toBeNull()
expect(await page.evaluate(() => window.sessionStorage.getItem('tronide.github.token'))).toBeNull()
})
})
16 changes: 16 additions & 0 deletions apps/remix-ide-pw/tests/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,22 @@ export async function gotoHome (page: Page) {
await page.locator('[data-id="landingWorkspaceStatus"]').waitFor({ timeout: 30_000 })
}

/**
* Seed only TronIDE's opaque BFF session for deterministic UI tests. This is
* not a GitHub credential; no PAT or OAuth access token enters browser storage.
*/
export async function seedGithubBffSession (page: Page, login = 'tron-tester') {
await page.evaluate(({ user }) => {
window.sessionStorage.setItem('tronide.github.session', 'test_bff_session_handle_012345678901234567890')
window.sessionStorage.setItem('tronide.github.user', user)
window.sessionStorage.removeItem('tronide.github.token')
window.localStorage.removeItem('tronide.github.token')
}, { user: login })
await page.reload({ waitUntil: 'domcontentloaded' })
await dismissWelcomeModal(page)
await page.locator('[data-id="landingWorkspaceStatus"]').waitFor({ timeout: 30_000 })
}

/** data-id selector for a row of the File Explorer tree. */
export function treeItem (path: string) {
return `[data-id="treeViewLitreeViewItem${path}"]`
Expand Down
Loading
Loading