-
Notifications
You must be signed in to change notification settings - Fork 15
INS-356: PoC: Full-stack preview for e2e verify #166
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
Closed
Changes from 5 commits
Commits
Show all changes
13 commits
Select commit
Hold shift + click to select a range
5108b22
feat(preview): experimental preview commands for isolated full-stack …
CarmenDou 16f06f1
fix(preview): address review — literal env values, early manifest, sa…
CarmenDou f64f000
fix(preview): cleanup branch on poll failure, overwrite all duplicate…
CarmenDou aa43a5b
fix(preview): validate name before provisioning, guard duplicate crea…
CarmenDou 3e1323f
fix(preview): gate teardown output behind --json, wrap teardown name …
CarmenDou 21c8689
feat(link): --with-test-agents installs Playwright Test Agents for in…
CarmenDou 48d0d38
Merge remote-tracking branch 'origin/main' into worktree-agent-e2e-pr…
CarmenDou 3dd19ac
docs(preview): add README covering verify-loop context, flow, design …
CarmenDou 0c743ab
fix(deployments): exclude Playwright test artifacts (test-results, pl…
CarmenDou 517a2a9
feat(verify): light-mode verify probes, default browser MCP, drop pre…
CarmenDou 26d56bf
chore(verify): remove dead overwriteEnvFile, simplify skills-only ins…
CarmenDou 29acf6c
fix(verify): guard read-only SQL, assert fetch ok, harden MCP merge, …
CarmenDou 1452c86
fix(verify): block destructive DML hidden inside a CTE in isReadOnlyQ…
CarmenDou File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,164 @@ | ||
| import { describe, it, expect, vi, beforeEach } from 'vitest'; | ||
| import { Command } from 'commander'; | ||
| import { promises as fs } from 'node:fs'; | ||
| import os from 'node:os'; | ||
| import path from 'node:path'; | ||
| import { registerPreviewCreateCommand } from './create.js'; | ||
| import { readPreviewManifest, writePreviewManifest } from '../../lib/preview-manifest.js'; | ||
| import { getBranchApi, deleteBranchApi } from '../../lib/api/platform.js'; | ||
|
|
||
| vi.mock('../../lib/api/platform.js', () => ({ | ||
| createBranchApi: vi.fn(async (_p: string, body: { mode: string; name: string }) => ({ | ||
| id: 'branch-123', | ||
| parent_project_id: 'p1', | ||
| organization_id: 'o1', | ||
| name: body.name, | ||
| appkey: 'p1ky-x9p', | ||
| region: 'us-east', | ||
| branch_state: 'creating', | ||
| branch_created_at: '2026-06-10T00:00:00.000Z', | ||
| branch_metadata: { mode: body.mode }, | ||
| })), | ||
| getBranchApi: vi.fn(async () => ({ | ||
| id: 'branch-123', | ||
| parent_project_id: 'p1', | ||
| organization_id: 'o1', | ||
| name: 'feat-likes', | ||
| appkey: 'p1ky-x9p', | ||
| region: 'us-east', | ||
| branch_state: 'ready', | ||
| branch_created_at: '2026-06-10T00:00:00.000Z', | ||
| branch_metadata: { mode: 'full' }, | ||
| })), | ||
| deleteBranchApi: vi.fn(async () => {}), | ||
| })); | ||
| vi.mock('../../lib/credentials.js', () => ({ requireAuth: vi.fn(async () => ({})) })); | ||
| vi.mock('../../lib/analytics.js', () => ({ | ||
| captureEvent: vi.fn(), | ||
| shutdownAnalytics: vi.fn(async () => {}), | ||
| })); | ||
|
|
||
| let tmpBase: string; | ||
| vi.mock('../../lib/config.js', () => ({ | ||
| getProjectConfig: vi.fn(() => ({ project_id: 'p1', branched_from: null })), | ||
| })); | ||
|
|
||
| describe('preview create', () => { | ||
| beforeEach(async () => { | ||
| vi.clearAllMocks(); | ||
| tmpBase = await fs.mkdtemp(path.join(os.tmpdir(), 'preview-cmd-')); | ||
| vi.spyOn(process, 'cwd').mockReturnValue(tmpBase); | ||
| }); | ||
|
|
||
| it('creates a branch and writes a manifest', async () => { | ||
| const program = new Command(); | ||
| program.exitOverride(); | ||
| const preview = program.command('preview'); | ||
| registerPreviewCreateCommand(preview); | ||
| await program.parseAsync(['preview', 'create', 'feat-likes'], { from: 'user' }); | ||
|
|
||
| const manifest = await readPreviewManifest(tmpBase, 'feat-likes'); | ||
| expect(manifest).not.toBeNull(); | ||
| expect(manifest?.branchId).toBe('branch-123'); | ||
| expect(manifest?.appkey).toBe('p1ky-x9p'); | ||
| }); | ||
|
|
||
| it('wires the given env file at the branch backend and backs it up', async () => { | ||
| const envFile = path.join(tmpBase, '.env.custom'); | ||
| await fs.writeFile( | ||
| envFile, | ||
| 'NEXT_PUBLIC_INSFORGE_URL=https://prod.insforge.app\n', | ||
| ); | ||
|
|
||
| const program = new Command(); | ||
| program.exitOverride(); | ||
| const preview = program.command('preview'); | ||
| registerPreviewCreateCommand(preview); | ||
| await program.parseAsync( | ||
| ['preview', 'create', 'feat-likes', '--wire-env', envFile], | ||
| { from: 'user' }, | ||
| ); | ||
|
|
||
| const content = await fs.readFile(envFile, 'utf-8'); | ||
| expect(content).toContain( | ||
| 'NEXT_PUBLIC_INSFORGE_URL=https://p1ky-x9p.us-east.insforge.app', | ||
| ); | ||
| expect(content).not.toContain('prod.insforge.app'); | ||
|
|
||
| const backup = await fs.readFile(envFile + '.preview-bak', 'utf-8'); | ||
| expect(backup).toContain('NEXT_PUBLIC_INSFORGE_URL=https://prod.insforge.app'); | ||
|
|
||
| const manifest = await readPreviewManifest(tmpBase, 'feat-likes'); | ||
| expect(manifest?.wiredEnvFile).toBe(envFile); | ||
|
coderabbitai[bot] marked this conversation as resolved.
Outdated
|
||
| }); | ||
|
|
||
| it('defaults --wire-env to .env.local and records it was created', async () => { | ||
| const program = new Command(); | ||
| program.exitOverride(); | ||
| const preview = program.command('preview'); | ||
| registerPreviewCreateCommand(preview); | ||
| // --wire-env with no value; .env.local does not exist in tmpBase yet. | ||
| await program.parseAsync(['preview', 'create', 'feat-likes', '--wire-env'], { from: 'user' }); | ||
|
|
||
| const created = await fs.readFile(path.join(tmpBase, '.env.local'), 'utf-8'); | ||
| expect(created).toContain('NEXT_PUBLIC_INSFORGE_URL=https://p1ky-x9p.us-east.insforge.app'); | ||
| const manifest = await readPreviewManifest(tmpBase, 'feat-likes'); | ||
| expect(manifest?.wiredEnvFile).toBe('.env.local'); | ||
| expect(manifest?.wiredEnvCreated).toBe(true); | ||
| }); | ||
|
|
||
| it('rolls back the branch when provisioning never reaches ready', async () => { | ||
| vi.mocked(getBranchApi).mockResolvedValueOnce({ | ||
| id: 'branch-123', | ||
| parent_project_id: 'p1', | ||
| organization_id: 'o1', | ||
| name: 'feat-likes', | ||
| appkey: 'p1ky-x9p', | ||
| region: 'us-east', | ||
| branch_state: 'conflicted', | ||
| branch_created_at: '2026-06-10T00:00:00.000Z', | ||
| branch_metadata: { mode: 'full' }, | ||
| } as Awaited<ReturnType<typeof getBranchApi>>); | ||
| const exitSpy = vi | ||
| .spyOn(process, 'exit') | ||
| .mockImplementation((() => { throw new Error('exit'); }) as never); | ||
|
|
||
| const program = new Command(); | ||
| program.exitOverride(); | ||
| const preview = program.command('preview'); | ||
| registerPreviewCreateCommand(preview); | ||
| await expect( | ||
| program.parseAsync(['preview', 'create', 'feat-likes'], { from: 'user' }), | ||
| ).rejects.toThrow(); | ||
|
|
||
| // The half-provisioned branch is cleaned up, and no manifest is left behind. | ||
| expect(deleteBranchApi).toHaveBeenCalledWith('branch-123', undefined); | ||
| expect(await readPreviewManifest(tmpBase, 'feat-likes')).toBeNull(); | ||
| exitSpy.mockRestore(); | ||
| }); | ||
|
|
||
| it('refuses to create when a preview of the same name already exists', async () => { | ||
| await writePreviewManifest(tmpBase, { | ||
| name: 'feat-likes', | ||
| branchId: 'old-branch', | ||
| appkey: 'old', | ||
| createdAt: '2026-06-10T00:00:00.000Z', | ||
| }); | ||
| const exitSpy = vi | ||
| .spyOn(process, 'exit') | ||
| .mockImplementation((() => { throw new Error('exit'); }) as never); | ||
|
|
||
| const program = new Command(); | ||
| program.exitOverride(); | ||
| const preview = program.command('preview'); | ||
| registerPreviewCreateCommand(preview); | ||
| await expect( | ||
| program.parseAsync(['preview', 'create', 'feat-likes'], { from: 'user' }), | ||
| ).rejects.toThrow(); | ||
|
|
||
| // The existing manifest is untouched (its branchId is not clobbered). | ||
| const manifest = await readPreviewManifest(tmpBase, 'feat-likes'); | ||
| expect(manifest?.branchId).toBe('old-branch'); | ||
| exitSpy.mockRestore(); | ||
| }); | ||
| }); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,142 @@ | ||
| import type { Command } from 'commander'; | ||
| import path from 'node:path'; | ||
| import { existsSync, copyFileSync } from 'node:fs'; | ||
| import { createBranchApi, getBranchApi, deleteBranchApi } from '../../lib/api/platform.js'; | ||
| import { CLIError, getRootOpts, handleError } from '../../lib/errors.js'; | ||
| import { requireAuth } from '../../lib/credentials.js'; | ||
| import { getProjectConfig } from '../../lib/config.js'; | ||
| import { outputJson, outputInfo } from '../../lib/output.js'; | ||
| import { captureEvent, shutdownAnalytics } from '../../lib/analytics.js'; | ||
| import { writePreviewManifest, readPreviewManifest, assertSafeName } from '../../lib/preview-manifest.js'; | ||
| import { overwriteEnvFile } from '../../lib/env-writer.js'; | ||
| import type { Branch } from '../../types.js'; | ||
|
|
||
| const POLL_INTERVAL_MS = 3_000; | ||
| const POLL_TIMEOUT_MS = 5 * 60 * 1_000; | ||
|
|
||
| export function registerPreviewCreateCommand(preview: Command): void { | ||
| preview | ||
| .command('create <name>') | ||
| .description('Create an isolated full-stack preview environment (experimental)') | ||
| .option( | ||
| '--wire-env [file]', | ||
| 'Point a frontend env file at the branch backend (default .env.local)', | ||
| ) | ||
| .action(async (name: string, opts: { wireEnv?: string | boolean }, cmd) => { | ||
| const { json, apiUrl } = getRootOpts(cmd); | ||
| try { | ||
| await requireAuth(apiUrl); | ||
| const project = getProjectConfig(); | ||
| if (!project) { | ||
| throw new CLIError('No project linked. Run `insforge link` first.'); | ||
| } | ||
| if (project.branched_from) { | ||
| throw new CLIError( | ||
| 'This directory is on a branch. Switch to the parent before creating a preview.', | ||
| ); | ||
| } | ||
|
|
||
| try { | ||
| assertSafeName(name); | ||
| } catch (e) { | ||
| throw new CLIError(e instanceof Error ? e.message : String(e)); | ||
| } | ||
|
|
||
| if (await readPreviewManifest(process.cwd(), name)) { | ||
| throw new CLIError( | ||
| `A preview named '${name}' already exists. Tear it down first: insforge preview teardown ${name}`, | ||
| ); | ||
| } | ||
|
|
||
| const created = await createBranchApi(project.project_id, { mode: 'full', name }, apiUrl); | ||
| captureEvent(project.project_id, 'cli_preview_create', { | ||
| mode: 'full', | ||
| parent_project_id: project.project_id, | ||
| }); | ||
|
|
||
| let ready: Branch; | ||
| try { | ||
| ready = await pollUntilReady(created.id, apiUrl); | ||
| } catch (pollErr) { | ||
| try { | ||
| await deleteBranchApi(created.id, apiUrl); | ||
|
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
|
||
| } catch { | ||
| // Best effort — fall through to the actionable error below. | ||
| } | ||
| const detail = pollErr instanceof Error ? pollErr.message : String(pollErr); | ||
| throw new CLIError( | ||
| `Preview '${name}' did not become ready: ${detail}. ` + | ||
| `If the branch still exists, remove it with: insforge branch delete ${name}`, | ||
| ); | ||
| } | ||
|
|
||
| const previewUrl = `https://${ready.appkey}.${ready.region}.insforge.app`; | ||
|
|
||
| await writePreviewManifest(process.cwd(), { | ||
|
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
|
||
| name, | ||
| branchId: ready.id, | ||
| appkey: ready.appkey, | ||
| createdAt: ready.branch_created_at, | ||
| }); | ||
|
|
||
| let wiredEnvFile: string | undefined; | ||
| if (opts.wireEnv) { | ||
| const envFile: string = typeof opts.wireEnv === 'string' ? opts.wireEnv : '.env.local'; | ||
| wiredEnvFile = envFile; | ||
| const envPath = path.resolve(process.cwd(), envFile); | ||
| // Back up an existing file so teardown can restore it. If the file | ||
| // doesn't exist, `overwriteEnvFile` creates it — record that so | ||
| // teardown deletes our creation instead of looking for a backup. | ||
| const envExisted = existsSync(envPath); | ||
| if (envExisted && !existsSync(envPath + '.preview-bak')) { | ||
| copyFileSync(envPath, envPath + '.preview-bak'); | ||
| } | ||
| overwriteEnvFile(envPath, { NEXT_PUBLIC_INSFORGE_URL: previewUrl }); | ||
|
|
||
| await writePreviewManifest(process.cwd(), { | ||
| name, | ||
| branchId: ready.id, | ||
| appkey: ready.appkey, | ||
| createdAt: ready.branch_created_at, | ||
| wiredEnvFile, | ||
| ...(envExisted ? {} : { wiredEnvCreated: true }), | ||
| }); | ||
| } | ||
|
|
||
| if (json) { | ||
| outputJson({ preview: { name, branchId: ready.id, appkey: ready.appkey, url: previewUrl } }); | ||
| } else { | ||
| outputInfo(`Preview '${name}' ready.`); | ||
| outputInfo(` Backend URL: ${previewUrl}`); | ||
| if (wiredEnvFile) { | ||
| outputInfo( | ||
| ` Wired ${wiredEnvFile}: NEXT_PUBLIC_INSFORGE_URL -> branch backend (backup: ${wiredEnvFile}.preview-bak)`, | ||
| ); | ||
| } | ||
| if (!wiredEnvFile) { | ||
| outputInfo(` Point your frontend at this backend (set NEXT_PUBLIC_INSFORGE_URL), then verify.`); | ||
| } | ||
| outputInfo(` Tear down when done: insforge preview teardown ${name}`); | ||
| } | ||
| } catch (err) { | ||
| handleError(err, json); | ||
| } finally { | ||
| await shutdownAnalytics(); | ||
| } | ||
| }); | ||
| } | ||
|
|
||
| async function pollUntilReady(branchId: string, apiUrl: string | undefined): Promise<Branch> { | ||
| const start = Date.now(); | ||
| while (Date.now() - start < POLL_TIMEOUT_MS) { | ||
| const branch = await getBranchApi(branchId, apiUrl); | ||
| if (branch.branch_state === 'ready') return branch; | ||
| if (branch.branch_state === 'deleted' || branch.branch_state === 'conflicted') { | ||
| throw new CLIError(`Preview creation failed (state: ${branch.branch_state})`); | ||
| } | ||
| await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS)); | ||
| } | ||
| const branch = await getBranchApi(branchId, apiUrl); | ||
| if (branch.branch_state === 'ready') return branch; | ||
| throw new CLIError('Preview creation timed out.'); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| import { describe, it, expect } from 'vitest'; | ||
| import { Command } from 'commander'; | ||
| import { registerPreviewCommands } from './index.js'; | ||
|
|
||
| describe('registerPreviewCommands', () => { | ||
| it('registers `preview` as a usable command', () => { | ||
| const program = new Command(); | ||
| registerPreviewCommands(program); | ||
| const found = program.commands.find((c) => c.name() === 'preview'); | ||
| expect(found).toBeDefined(); | ||
| }); | ||
|
|
||
| it('hides `preview` from help output (behavior, not internals)', () => { | ||
| const program = new Command(); | ||
| program.name('insforge'); | ||
| registerPreviewCommands(program); | ||
| // Assert observable behavior — hidden commands are excluded from help — | ||
| // rather than Commander's private `_hidden` field, which can change. | ||
| expect(program.helpInformation()).not.toContain('preview'); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| // src/commands/preview/index.ts | ||
| import type { Command } from 'commander'; | ||
| import { registerPreviewCreateCommand } from './create.js'; | ||
| import { registerPreviewTeardownCommand } from './teardown.js'; | ||
|
|
||
| export function registerPreviewCommands(program: Command): void { | ||
| const preview = program | ||
| .command('preview', { hidden: true }) | ||
| .description('[experimental] Isolated full-stack preview environments'); | ||
| registerPreviewCreateCommand(preview); | ||
| registerPreviewTeardownCommand(preview); | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.