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
129 changes: 129 additions & 0 deletions .github/workflows/setup-smoke.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
name: Setup Smoke

on:
schedule:
- cron: '17 * * * *'
workflow_dispatch:

permissions:
contents: read
issues: write

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
setup-smoke:
runs-on: ${{ vars.RUNNER_LARGE_LABEL || 'ubuntu-24.04-8core' }}
timeout-minutes: 45

env:
NODE_ENV: development
DEBUG_SHOW_DEV_UI: 'true'
PORT: '3000'
NEXTAUTH_URL: http://localhost:3000
POSTGRES_URL: postgresql://postgres:postgres@localhost:5432/postgres
POSTGRES_CONNECT_TIMEOUT: '30000'
POSTGRES_MAX_QUERY_TIME: '30000'
SKIP_STRIPE_API: 'true'
NEXT_PUBLIC_POSTHOG_KEY: fake-token
PLAYWRIGHT_BASE_URL: http://localhost:3000

steps:
- uses: useblacksmith/checkout@41cdeedae8edb2e684ba22896a5fd2a3cb85db6b # v1
with:
lfs: true

- name: Setup pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0

- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version-file: '.nvmrc'
cache: 'pnpm'

- name: Enable Corepack
run: |
corepack enable
corepack prepare pnpm@11.1.2 --activate

- name: Install tmux
run: |
sudo apt-get update
sudo apt-get install -y tmux

- name: Install dependencies
run: pnpm install --frozen-lockfile

- name: Generate local environment
run: pnpm dev:setup-env --ci

- name: Sync local service environment
run: pnpm dev:env -y

- name: Start local infrastructure
run: docker compose -f dev/docker-compose.yml up -d --wait

- name: Verify migrations bootstrap cleanly
run: pnpm drizzle:verify-bootstrap

- name: Run database migrations
run: pnpm drizzle migrate

- name: Install Playwright browsers
run: pnpm --filter web exec playwright install --with-deps chromium

- name: Start dev stack
run: pnpm dev:start --no-attach

- name: Wait for web app
run: |
for i in {1..60}; do
if curl -fsSL http://localhost:3000/users/sign_in >/dev/null; then
exit 0
fi

pnpm dev:status
sleep 5
done

echo "Web app did not become ready in time"
exit 1

- name: Run setup smoke tests
run: pnpm test:setup-smoke

- name: Stop dev stack
if: always()
run: pnpm dev:stop --force

- name: Upload setup smoke artifacts
if: always()
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: setup-smoke-artifacts
path: |
apps/web/playwright-report
apps/web/test-results
dev/logs
retention-days: 7

- name: Notify setup smoke failure
if: failure()
env:
GH_TOKEN: ${{ github.token }}
ISSUE_NUMBER: '3791'
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
cat > /tmp/setup-smoke-failure.md <<EOF
@RSO Setup Smoke failed.

Run: ${RUN_URL}
Ref: ${GITHUB_REF_NAME}
SHA: ${GITHUB_SHA}
Trigger: ${GITHUB_EVENT_NAME}
EOF

gh issue comment "$ISSUE_NUMBER" --body-file /tmp/setup-smoke-failure.md
1 change: 1 addition & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
"db:empty-database": "tsx --tsconfig tsconfig.scripts.json src/db/empty-database.ts",
"knip": "knip",
"test:e2e": "playwright test",
"test:setup-smoke": "playwright test --config=playwright.setup-smoke.config.ts",
"test:e2e:ui": "playwright test --ui",
"test:e2e:debug": "playwright test --debug",
"promo": "pnpm -s script src/scripts/encrypt-promo-codes.ts"
Expand Down
27 changes: 27 additions & 0 deletions apps/web/playwright.setup-smoke.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { defineConfig, devices } from '@playwright/test';

const port = process.env.PORT ? Number(process.env.PORT) : 3000;
const baseURL = process.env.PLAYWRIGHT_BASE_URL ?? `http://localhost:${port}`;

export default defineConfig({
testDir: './tests/setup-smoke',
fullyParallel: false,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: 1,
reporter: process.env.CI ? 'html' : 'list',
outputDir: 'test-results/setup-smoke',
use: {
baseURL,
trace: 'on-first-retry',
},
projects: [
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
viewport: { width: 1440, height: 900 },
},
},
],
});
18 changes: 17 additions & 1 deletion apps/web/src/lib/stripe-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ const stripeSecretKey = getEnvVariable('STRIPE_SECRET_KEY');
if (!stripeSecretKey) {
throw new Error('STRIPE_SECRET_KEY environment variable is not set');
}
const skipStripeApi =
process.env.NODE_ENV !== 'production' && process.env.SKIP_STRIPE_API === 'true';

export const client: Stripe = new Stripe(stripeSecretKey);

Expand All @@ -26,11 +28,23 @@ type UserConstrainedMetadata = {

type CreateParams = Omit<Stripe.CustomerCreateParams, 'metadata'> & ConstrainedMetadata;

export async function createStripeCustomer(customer: CreateParams) {
export async function createStripeCustomer(
customer: CreateParams
): Promise<Pick<Stripe.Customer, 'id'>> {
if (skipStripeApi) {
const metadataId =
'kiloUserId' in customer.metadata
? customer.metadata.kiloUserId
: customer.metadata.organizationId;
return { id: `cus_local_${metadataId}` };
}

return client.customers.create(customer);
}

export async function deleteStripeCustomer(stripeCustomerId: string) {
if (skipStripeApi) return;

await client.customers.del(stripeCustomerId);
}

Expand All @@ -56,6 +70,8 @@ export async function hasPaymentMethodInStripe({
}: {
stripeCustomerId: string;
}): Promise<boolean> {
if (skipStripeApi) return false;

// This function may become redundant if our in-db administration is accurate.
const paymentMethods = await client.paymentMethods.list({
customer: stripeCustomerId,
Expand Down
65 changes: 65 additions & 0 deletions apps/web/tests/setup-smoke/profile.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { expect, test } from '@playwright/test';
import { createDrizzleClient } from '@kilocode/db/client';
import { kilocode_users } from '@kilocode/db/schema';
import { hosted_domain_specials } from '@/lib/auth/constants';
import { randomUUID } from 'node:crypto';

function isSignedInDestination(url: URL): boolean {
return url.pathname === '/profile' || url.pathname.startsWith('/organizations/');
}

test.describe('local setup smoke', () => {
test.use({ storageState: { cookies: [], origins: [] } });

test('signs in with fake auth and renders the profile page', async ({ page }) => {
const uniqueId = randomUUID().slice(0, 8);
const testEmail = `setup-smoke-${uniqueId}+stytchpass@example.com`;
const signInUrl = `/users/sign_in?fakeUser=${encodeURIComponent(testEmail)}&callbackPath=${encodeURIComponent('/profile')}`;
const postgresUrl = process.env.POSTGRES_URL;
if (!postgresUrl) throw new Error('POSTGRES_URL must be set for setup smoke tests');

const { db, pool } = createDrizzleClient({
connectionString: postgresUrl,
poolConfig: {
connectionTimeoutMillis: Number.parseInt(process.env.POSTGRES_CONNECT_TIMEOUT ?? '30000'),
max: 1,
},
});

try {
await db.insert(kilocode_users).values({
id: `setup-smoke-${uniqueId}`,
google_user_email: testEmail,
google_user_name: `setup-smoke-${uniqueId}`,
google_user_image_url: '',
hosted_domain: hosted_domain_specials.fake_devonly,
stripe_customer_id: `cus_setup_smoke_${uniqueId}`,
completed_welcome_form: true,
has_validation_stytch: true,
});
} finally {
await pool.end();
}

await page.goto(signInUrl);
await page.waitForURL(
url => url.pathname === '/customer-source-survey' || isSignedInDestination(url),
Comment thread
RSO marked this conversation as resolved.
{ timeout: 30_000, waitUntil: 'networkidle' }
);

if (new URL(page.url()).pathname === '/customer-source-survey') {
await page.getByRole('button', { name: 'Skip' }).click();
await page.waitForURL(url => isSignedInDestination(url), {
timeout: 15_000,
waitUntil: 'networkidle',
});
}

const profileResponse = await page.goto('/profile', { waitUntil: 'domcontentloaded' });
expect(profileResponse?.ok()).toBe(true);
await expect(page).toHaveURL(/\/profile$/);
await expect(page.getByRole('link', { name: 'Your Profile' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Edit profile' })).toBeVisible();
await expect(page.getByText(testEmail)).toBeVisible();
});
});
34 changes: 29 additions & 5 deletions dev/local/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,10 @@ const CAPTURE_TIMEOUT_MS = 30_000;
// Commands
// ---------------------------------------------------------------------------

async function cmdUp(targets: string[], repoRoot: string): Promise<void> {
async function cmdUp(args: string[], repoRoot: string): Promise<void> {
const noAttach = args.includes('--no-attach');
const targets = args.filter(arg => arg !== '--no-attach');

// --- Preflight checks ---
if (!isTmuxAvailable()) {
console.error('tmux is not installed. Install it with: brew install tmux');
Expand Down Expand Up @@ -126,8 +129,12 @@ async function cmdUp(targets: string[], repoRoot: string): Promise<void> {
// --- Check for existing session ---
const sessionName = getSessionName();
if (sessionExists(sessionName)) {
console.log(`Session ${sessionName} already running — attaching.`);
attachSession(sessionName);
console.log(
noAttach
? `Session ${sessionName} already running.`
: `Session ${sessionName} already running — attaching.`
);
if (!noAttach) attachSession(sessionName);
return;
}

Expand Down Expand Up @@ -194,11 +201,27 @@ async function cmdUp(targets: string[], repoRoot: string): Promise<void> {
const wranglerRegistryPath = getWranglerRegistryPath(repoRoot);
const sessionEnv: Record<string, string> = {
KILO_PORT_OFFSET: String(portOffset),
PATH: process.env.PATH ?? '',
WRANGLER_REGISTRY_PATH: wranglerRegistryPath,
};
if (process.env.PNPM_HOME !== undefined && process.env.PNPM_HOME !== '') {
sessionEnv.PNPM_HOME = process.env.PNPM_HOME;
}
if (process.env.PORT !== undefined && process.env.PORT !== '') {
sessionEnv.PORT = String(getService('nextjs').port);
}
if (process.env.DEBUG_SHOW_DEV_UI !== undefined && process.env.DEBUG_SHOW_DEV_UI !== '') {
sessionEnv.DEBUG_SHOW_DEV_UI = process.env.DEBUG_SHOW_DEV_UI;
}
if (process.env.SKIP_STRIPE_API !== undefined && process.env.SKIP_STRIPE_API !== '') {
sessionEnv.SKIP_STRIPE_API = process.env.SKIP_STRIPE_API;
}
if (
process.env.NEXT_PUBLIC_POSTHOG_KEY !== undefined &&
process.env.NEXT_PUBLIC_POSTHOG_KEY !== ''
) {
sessionEnv.NEXT_PUBLIC_POSTHOG_KEY = process.env.NEXT_PUBLIC_POSTHOG_KEY;
}
createSession(sessionName, sessionEnv);

// --- Start each service in its own tmux window ---
Expand Down Expand Up @@ -401,7 +424,7 @@ async function cmdUp(targets: string[], repoRoot: string): Promise<void> {
console.log(
`${GREEN}Started ${startedServices.length} services in session ${sessionName}${RESET}`
);
attachSession(sessionName);
if (!noAttach) attachSession(sessionName);
}

type ServiceStatus = 'up' | 'down';
Expand Down Expand Up @@ -583,7 +606,8 @@ async function cmdEnv(args: string[], repoRoot: string): Promise<void> {
function printUsage(): void {
console.log(`
Usage:
dev:start [targets...] Start services (default: core)
dev:start [--no-attach] [targets...]
Start services (default: core)
dev:stop [--force] Stop all services (skips shared Docker infra if
other kilo-dev sessions are running; --force overrides)
dev:status [--json] Show running services and their ports
Expand Down
30 changes: 28 additions & 2 deletions dev/local/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,16 +92,42 @@ export function buildStartCommand(serviceName: string): string {
// would try to descend into <dir>/<dir> which doesn't exist.
if (svc.dir !== '.' && svc.command[0] === 'pnpm') {
const [, ...rest] = svc.command;
return `pnpm --filter {./${svc.dir}} ${rest.join(' ')}`;
return `${getCommandEnvironmentPrefix()}${getPnpmCommand()} --filter {./${svc.dir}} ${rest.join(' ')}`;
}

const parts: string[] = [];
if (svc.dir !== '.') parts.push(`cd ${shellQuote(path.join(findRepoRoot(), svc.dir))}`);
parts.push(svc.command.join(' '));
parts.push(`${getCommandEnvironmentPrefix()}${svc.command.join(' ')}`);

return parts.join(' && ');
}

function getCommandEnvironmentPrefix(): string {
const env = ['PATH', 'PNPM_HOME', 'COREPACK_HOME', 'npm_execpath']
.map(key => {
const value = process.env[key];
return value === undefined || value === '' ? undefined : `${key}=${shellQuote(value)}`;
})
.filter(value => value !== undefined);

return env.length === 0 ? '' : `${env.join(' ')} `;
}

function getPnpmCommand(): string {
const pnpmHome = process.env.PNPM_HOME;
if (pnpmHome) {
const pnpmPath = path.join(pnpmHome, 'pnpm');
if (fs.existsSync(pnpmPath)) return shellQuote(pnpmPath);
}

const npmExecPath = process.env.npm_execpath;
if (npmExecPath && npmExecPath.includes('pnpm')) {
return `node ${shellQuote(npmExecPath)}`;
}

return 'pnpm';
}

// ---------------------------------------------------------------------------
// Tmux service lifecycle
// ---------------------------------------------------------------------------
Expand Down
Loading