diff --git a/cli/src/bundle/upload.ts b/cli/src/bundle/upload.ts index 6187e10ea2..741bdb68e8 100644 --- a/cli/src/bundle/upload.ts +++ b/cli/src/bundle/upload.ts @@ -823,6 +823,10 @@ function formatRolloutPercentage(bps: number) { return `${Number((bps / 100).toFixed(2))}%` } +function channelHasProgressiveRollout(channel: Pick) { + return channel.rollout_enabled || channel.rollout_version != null +} + async function getVersionIdForChannelUpdate(supabase: SupabaseType, apikey: string, appid: string, bundle: string) { const { data: versionId } = await supabase .rpc('get_app_versions', { apikey, name_version: bundle, appid }) @@ -1058,7 +1062,7 @@ async function promoteExistingChannel( targetChannel: UploadTargetChannel, localConfig: localConfigType, displayBundleUrl: boolean, - options?: { supaHost?: string, supaAnon?: string }, + options?: Pick, ): Promise { const { error } = await invokeCapgoCliApi('bundle', { apikey, @@ -1067,6 +1071,7 @@ async function promoteExistingChannel( app_id: appid, version_id: versionId, channel_id: targetChannel.id, + ...(options?.stable ? { target: 'stable' } : {}), }, supaHost: options?.supaHost, supaAnon: options?.supaAnon, @@ -1077,8 +1082,8 @@ async function promoteExistingChannel( } const bundleUrl = `${localConfig.hostWeb}/app/${appid}/channel/${targetChannel.id}` - if (targetChannel.rollout_enabled && targetChannel.rollout_version != null) { - log.warn('This channel has an active progressive rollout. Linking this bundle as the stable version resets that rollout, so devices receive the new bundle instead of the previous rollout target.') + if (!options?.stable && channelHasProgressiveRollout(targetChannel)) { + log.info('Channel has progressive rollout configured. This bundle was set as the rollout target; stable bundle stays unchanged.') } else if (targetChannel.public) { log.info('Your update is now available in your public channel ๐ŸŽ‰') @@ -1105,7 +1110,7 @@ async function setVersionInChannel( targetChannel: UploadTargetChannel | null, requireChannelAssignment = false, selfAssign?: boolean, - cliHost?: { supaHost?: string, supaAnon?: string }, + options?: Pick, ): Promise { const canPromoteTargetChannel = targetChannel !== null && await hasCliPermission(supabase, apikey, 'channel.promote_bundle', { appId: appid, channelId: targetChannel.id }) @@ -1122,42 +1127,29 @@ async function setVersionInChannel( if (targetChannel && canPromoteTargetChannel) { const versionId = await getVersionIdForChannelUpdate(supabase, apikey, appid, bundle) - if (selfAssign) { - const canUpdateChannelSettings = await hasCliPermission(supabase, apikey, 'channel.update_settings', { appId: appid, channelId: targetChannel.id }) - if (!canUpdateChannelSettings) { - log.warn('Cannot enable device self-assign because this API key lacks channel.update_settings') - return promoteExistingChannel(apikey, appid, versionId, targetChannel, localConfig, displayBundleUrl, cliHost) - } - } + const promoted = await promoteExistingChannel(apikey, appid, versionId, targetChannel, localConfig, displayBundleUrl, options) + if (!promoted) + return false if (!selfAssign) - return promoteExistingChannel(apikey, appid, versionId, targetChannel, localConfig, displayBundleUrl, cliHost) + return true + + const canUpdateChannelSettings = await hasCliPermission(supabase, apikey, 'channel.update_settings', { appId: appid, channelId: targetChannel.id }) + if (!canUpdateChannelSettings) { + log.warn('Cannot enable device self-assign because this API key lacks channel.update_settings') + return true + } - const { error: dbError3, data } = await updateOrCreateChannel(supabase, { + const { error: dbError3 } = await updateOrCreateChannel(supabase, { name: channel, app_id: appid, created_by: userId, - version: versionId, owner_org: orgId, - ...(selfAssign ? { allow_device_self_set: true } : {}), + allow_device_self_set: true, }) if (dbError3) { await uploadFailIfChannelError(dbError3, () => `Cannot set channel because this API key does not have the required RBAC permission. ${formatError(dbError3)}`) } - if (data?.id) { - const bundleUrl = `${localConfig.hostWeb}/app/${appid}/channel/${data.id}` - if (targetChannel.rollout_enabled && targetChannel.rollout_version != null) { - log.warn('This channel has an active progressive rollout. Linking this bundle as the stable version resets that rollout, so devices receive the new bundle instead of the previous rollout target.') - } - else if (data.public) { - log.info('Your update is now available in your public channel ๐ŸŽ‰') - } - else { - log.info(`Link device to this bundle to try it: ${bundleUrl}`) - } - if (displayBundleUrl) - log.info(`Bundle url: ${bundleUrl}`) - } return true } @@ -1173,8 +1165,8 @@ async function setVersionInChannel( version: bundle, ...(selfAssign ? { allow_device_self_set: true } : {}), }, - supaHost: cliHost?.supaHost, - supaAnon: cliHost?.supaAnon, + supaHost: options?.supaHost, + supaAnon: options?.supaAnon, }) if (error) { await uploadFailIfChannelError(error, async () => `Cannot create channel and set its bundle because this API key does not have the required RBAC permission. ${await formatFunctionInvokeError(error)}`) @@ -2178,6 +2170,9 @@ export function checkValidOptions(options: OptionsUpload) { if (options.rolloutCacheTtlSeconds != null && (!Number.isInteger(options.rolloutCacheTtlSeconds) || options.rolloutCacheTtlSeconds < 60 || options.rolloutCacheTtlSeconds > 31536000)) { uploadFail('Rollout cache TTL seconds must be between 60 and 31536000') } + if (options.stable === true && hasUploadRollout) { + uploadFail('You cannot use --stable together with --rollout, --rollout-percentage-bps, or --rollout-advance') + } if (hasUploadRollout && options.dryUpload) { uploadFail('You cannot use --rollout or --rollout-advance with --dry-upload because dry upload does not update channels') } diff --git a/cli/src/index.ts b/cli/src/index.ts index 49730ccc5d..87530148c2 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -245,6 +245,7 @@ Example: npx @capgo/cli@latest bundle upload com.example.app --path ./dist --cha .option('--rollout ', `Set the uploaded bundle as this channel's rollout target at a percentage from 0 to 100`, value => Number.parseFloat(value)) .option('--rollout-percentage-bps ', `Set the uploaded bundle rollout percentage in basis points from 0 to 10000`, value => Number.parseInt(value, 10)) .option('--rollout-advance', `Promote the current rollout target to stable, then set the uploaded bundle as the new rollout. Reuses the previous percentage unless --rollout or --rollout-percentage-bps is also set`) + .option('--stable', `Assign the uploaded bundle to the channel stable version instead of the rollout target when progressive rollout is configured`) .option('--rollout-cache-ttl-seconds ', `Cloudflare rollout decision cache TTL in seconds`, value => Number.parseInt(value, 10)) .option('-e, --external ', `Link to external URL instead of upload to Capgo Cloud`) .option('--iv-session-key ', `Set the IV and session key for bundle URL external`) diff --git a/cli/src/schemas/bundle.ts b/cli/src/schemas/bundle.ts index 237f6bc17a..afe1c5dbb7 100644 --- a/cli/src/schemas/bundle.ts +++ b/cli/src/schemas/bundle.ts @@ -12,6 +12,7 @@ export const optionsUploadSchema = optionsBaseSchema.extend({ rollout: z.number().min(0).max(100).optional(), rolloutPercentageBps: z.number().int().min(0).max(10000).optional(), rolloutAdvance: z.boolean().optional(), + stable: z.boolean().optional(), rolloutCacheTtlSeconds: z.number().int().min(60).max(31536000).optional(), displayIvSession: z.boolean().optional(), external: z.string().optional(), diff --git a/cli/test/test-fail-on-incompatible.mjs b/cli/test/test-fail-on-incompatible.mjs index 83993a590f..0891541cfa 100644 --- a/cli/test/test-fail-on-incompatible.mjs +++ b/cli/test/test-fail-on-incompatible.mjs @@ -253,6 +253,17 @@ test('--rollout-advance alone does not trigger a dry-upload conflict', () => { assert.doesNotThrow(() => checkValidOptions({ rolloutAdvance: true })) }) +test('--stable with rollout options is rejected', () => { + assert.throws( + () => checkValidOptions({ stable: true, rolloutAdvance: true }), + (error) => { + assert.ok(error instanceof Error, 'expected an Error to be thrown') + assert.match(error.message, /--stable/, 'message should mention --stable') + return true + }, + ) +}) + console.log('\n๐Ÿงช Testing rejectOrAcceptIncompatibleChannelBundle...\n') test('channel set throws when incompatible and not accepted', () => { diff --git a/docs/pr-screenshots/3313/after-local-bundle-assign-dialog.png b/docs/pr-screenshots/3313/after-local-bundle-assign-dialog.png new file mode 100644 index 0000000000..25cd605139 Binary files /dev/null and b/docs/pr-screenshots/3313/after-local-bundle-assign-dialog.png differ diff --git a/docs/pr-screenshots/3313/after-local-desktop-rollout-section.png b/docs/pr-screenshots/3313/after-local-desktop-rollout-section.png new file mode 100644 index 0000000000..84b8143586 Binary files /dev/null and b/docs/pr-screenshots/3313/after-local-desktop-rollout-section.png differ diff --git a/docs/pr-screenshots/3313/after-local-mobile-rollout-section.png b/docs/pr-screenshots/3313/after-local-mobile-rollout-section.png new file mode 100644 index 0000000000..3de6915553 Binary files /dev/null and b/docs/pr-screenshots/3313/after-local-mobile-rollout-section.png differ diff --git a/docs/pr-screenshots/3313/before-preprod-desktop-channel-information.png b/docs/pr-screenshots/3313/before-preprod-desktop-channel-information.png new file mode 100644 index 0000000000..1b38c5f316 Binary files /dev/null and b/docs/pr-screenshots/3313/before-preprod-desktop-channel-information.png differ diff --git a/docs/pr-screenshots/3313/before-preprod-mobile-channel-information.png b/docs/pr-screenshots/3313/before-preprod-mobile-channel-information.png new file mode 100644 index 0000000000..53e8597ec4 Binary files /dev/null and b/docs/pr-screenshots/3313/before-preprod-mobile-channel-information.png differ diff --git a/messages/en.context.json b/messages/en.context.json index 91cdca9c2f..3be6a980e7 100644 --- a/messages/en.context.json +++ b/messages/en.context.json @@ -3398,5 +3398,36 @@ "your-api-key": "Used in Capgo web console areas: services. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "your-role-in-org": "Used in Capgo web console areas: pages. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "your-usage": "Used in Capgo web console areas: pages/settings/organization. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", - "zip-bundle": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged." + "zip-bundle": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "change-rollout-target": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "complete-rollout": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "rollback-rollout": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "enable-rollout": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "disable-rollout": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "pause-rollout": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "resume-rollout": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "apply-rollout-percentage": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "rollout-stable-tooltip": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "rollout-target-tooltip": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "rollout-percentage-tooltip": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "rollout-cache-ttl-tooltip": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "rollout-promote-confirm-title": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "rollout-promote-confirm-description": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "rollout-promote-confirm-action": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "rollout-rollback-confirm-title": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "rollout-rollback-confirm-description": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "rollout-rollback-confirm-action": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "rollout-disable-confirm-title": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "rollout-disable-confirm-description": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "rollout-disable-confirm-action": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "rollout-pause-confirm-title": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "rollout-pause-confirm-description": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "rollout-resume-confirm-title": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "rollout-resume-confirm-description": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "channel-bundle-assign-rollout-title": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "channel-bundle-assign-rollout-hint": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "channel-bundle-assign-auto": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "channel-bundle-assign-rollout": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "channel-bundle-assign-stable": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "rollout-requires-stable-bundle": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged." } diff --git a/messages/en.json b/messages/en.json index 7ba19418fd..79e0711802 100644 --- a/messages/en.json +++ b/messages/en.json @@ -3140,14 +3140,15 @@ "translation-unavailable": "This language is not available right now.", "progressive-rollout": "Progressive rollout", "set-rollout-target": "Set target", - "rollout-target": "Target", + "change-rollout-target": "Change rollout target", + "rollout-target": "Rollout target", "stable-fallback": "Stable fallback", "channel-version-with-rollout": "{fallback} ยท serving {target}", - "rollout-settings-help": "Selected devices receive this bundle. Everyone else stays on the stable fallback. Linking a new channel bundle resets this rollout unless you set a new target at the same time.", + "rollout-settings-help": "Selected devices receive the rollout target. Everyone else stays on stable. New uploads and bundle links use the rollout path by default while progressive rollout is configured.", "rollout-delivery-all-title": "All devices receive {target}", "rollout-delivery-split-title": "{percent} of devices receive {target}", "rollout-delivery-fallback-title": "Devices receive {fallback}", - "rollout-delivery-upload-hint": "Linking a new channel bundle resets this rollout so devices receive that bundle. Use --rollout to keep serving a percentage of devices.", + "rollout-delivery-upload-hint": "New uploads and bundle links on this channel go to the rollout target by default. Stable stays unchanged unless you choose to replace it.", "rollout-delivery-paused-hint": "Rollout is paused, so target {target} is not served.", "rollout-delivery-zero-hint": "Rollout is 0%, so target {target} is not served.", "rollout-percentage": "Rollout percentage", @@ -3158,6 +3159,36 @@ "pause": "Pause", "resume": "Resume", "promote": "Promote", + "complete-rollout": "Complete rollout", + "rollback-rollout": "Rollback rollout", + "enable-rollout": "Enable rollout", + "disable-rollout": "Disable rollout", + "pause-rollout": "Pause rollout", + "resume-rollout": "Resume rollout", + "apply-rollout-percentage": "Apply percentage", + "rollout-stable-tooltip": "Stable is the fallback bundle. Devices not selected for rollout keep receiving this version.", + "rollout-target-tooltip": "Rollout target is the bundle gradually released to a percentage of devices. Everyone else stays on stable.", + "rollout-percentage-tooltip": "Share of devices that receive the rollout target. The rest stay on stable until you complete or raise the percentage.", + "rollout-cache-ttl-tooltip": "How long Cloudflare caches each device's rollout decision. Lower values react faster to percentage changes.", + "rollout-promote-confirm-title": "Complete progressive rollout?", + "rollout-promote-confirm-description": "This makes {target} the new stable bundle for everyone and ends progressive rollout. Stable {stable} is replaced. Rollout percentage resets to 0% and cannot be undone from this screen.", + "rollout-promote-confirm-action": "Complete rollout", + "rollout-rollback-confirm-title": "Rollback progressive rollout?", + "rollout-rollback-confirm-description": "This removes rollout target {target}. Devices return to stable {stable} only. The rollout target bundle is unlinked from this channel.", + "rollout-rollback-confirm-action": "Rollback rollout", + "rollout-disable-confirm-title": "Disable progressive rollout?", + "rollout-disable-confirm-description": "This turns off rollout and removes rollout target {target} from this channel. Devices keep stable {stable}.", + "rollout-disable-confirm-action": "Disable rollout", + "rollout-pause-confirm-title": "Pause progressive rollout?", + "rollout-pause-confirm-description": "New devices stop receiving rollout target {target}. Devices already on the rollout keep it until cache expires. Stable {stable} is unchanged.", + "rollout-resume-confirm-title": "Resume progressive rollout?", + "rollout-resume-confirm-description": "Rollout target {target} is served again to {percent} of devices. Stable {stable} stays the fallback.", + "channel-bundle-assign-rollout-title": "This channel uses progressive rollout", + "channel-bundle-assign-rollout-hint": "Choose where this bundle should land. Auto uses rollout when progressive rollout is configured.", + "channel-bundle-assign-auto": "Auto (recommended)", + "channel-bundle-assign-rollout": "Rollout target", + "channel-bundle-assign-stable": "Replace stable", + "rollout-requires-stable-bundle": "Set a stable bundle on this channel before using progressive rollout.", "notify": "Notify", "auto-pause": "Auto-pause", "failure-rate-bps": "Failure bps", diff --git a/scripts/capture-pr-3313-before-preprod.mjs b/scripts/capture-pr-3313-before-preprod.mjs new file mode 100644 index 0000000000..33f7fd95a0 --- /dev/null +++ b/scripts/capture-pr-3313-before-preprod.mjs @@ -0,0 +1,104 @@ +import { chromium } from '@playwright/test' +import { mkdir, readdir, unlink } from 'node:fs/promises' +import path from 'node:path' + +const EMAIL = process.env.CAPGO_SCREENSHOT_EMAIL ?? 'test@capgo.app' +const PASSWORD = process.env.CAPGO_PREPROD_DEMO_PASSWORD ?? process.env.CAPGO_SCREENSHOT_PASSWORD +if (!PASSWORD) { + throw new Error('Set CAPGO_PREPROD_DEMO_PASSWORD or CAPGO_SCREENSHOT_PASSWORD before capturing preprod screenshots') +} +const OUT_DIR = path.resolve('docs/pr-screenshots/3313') +const BASE_URL = 'https://console.preprod.capgo.app' +const APP_ID = 'com.demo.app' +const CHANNEL_ID = process.env.CAPGO_SCREENSHOT_CHANNEL_ID ?? '1' +const PREPROD_SUPABASE_URL = 'https://ibwjdnhknbkcqfbabwei.supabase.co' +const PREPROD_ANON_KEY = 'sb_publishable_q_TJ5x2krpmTYIcRDknJJQ_fvKFqKYz' +const PREPROD_STORAGE_KEY = 'sb-ibwjdnhknbkcqfbabwei-auth-token' + +async function fetchPreprodSession() { + const response = await fetch(`${PREPROD_SUPABASE_URL}/auth/v1/token?grant_type=password`, { + method: 'POST', + headers: { + apikey: PREPROD_ANON_KEY, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ email: EMAIL, password: PASSWORD }), + }) + const data = await response.json() + if (!response.ok || !data.access_token) + throw new Error(`Preprod auth failed: ${data.msg || data.error_description || response.status}`) + return { + access_token: data.access_token, + refresh_token: data.refresh_token, + expires_at: Math.floor(Date.now() / 1000) + (data.expires_in ?? 3600), + expires_in: data.expires_in ?? 3600, + token_type: data.token_type ?? 'bearer', + user: data.user, + } +} + +async function login(page) { + const session = await fetchPreprodSession() + await page.goto(`${BASE_URL}/login/`, { waitUntil: 'domcontentloaded', timeout: 120000 }) + await page.evaluate(({ storageKey, sessionData }) => { + localStorage.setItem(storageKey, JSON.stringify(sessionData)) + }, { storageKey: PREPROD_STORAGE_KEY, sessionData: session }) + await page.goto(`${BASE_URL}/apps`, { waitUntil: 'domcontentloaded', timeout: 120000 }) + await page.waitForURL(/\/(apps|dashboard|onboarding|app)(\/|$)/, { timeout: 60000 }) +} + +async function dismissChrome(page) { + const remind = page.getByRole('button', { name: /remind me later/i }) + if (await remind.count()) + await remind.click({ timeout: 3000 }).catch(() => {}) +} + +async function captureChannelInformation(page, fileName, viewport) { + await page.setViewportSize(viewport) + await page.goto(`${BASE_URL}/app/${APP_ID}/channel/${CHANNEL_ID}`, { + waitUntil: 'domcontentloaded', + timeout: 120000, + }) + await page.waitForLoadState('networkidle', { timeout: 25000 }).catch(() => {}) + await page.waitForTimeout(1500) + await dismissChrome(page) + + const bodyText = await page.locator('body').innerText() + if (/channel not found/i.test(bodyText)) + throw new Error(`Channel ${CHANNEL_ID} not found on ${BASE_URL}`) + if (/progressive rollout/i.test(bodyText)) + throw new Error('Deployed preprod unexpectedly shows progressive rollout UI') + + const panel = page.locator('dl').first() + await panel.scrollIntoViewIfNeeded() + await panel.screenshot({ path: path.join(OUT_DIR, fileName) }) +} + +async function clearOutDir() { + await mkdir(OUT_DIR, { recursive: true }) + for (const file of await readdir(OUT_DIR)) { + if (file.endsWith('.png')) + await unlink(path.join(OUT_DIR, file)) + } +} + +async function main() { + await clearOutDir() + const browser = await chromium.launch({ headless: true }) + const context = await browser.newContext({ viewport: { width: 1280, height: 900 } }) + const page = await context.newPage() + page.setDefaultTimeout(120000) + + console.log(`[capture] BEFORE hosted preprod: ${BASE_URL}`) + await login(page) + await captureChannelInformation(page, 'before-preprod-desktop-channel-information.png', { width: 1280, height: 900 }) + await captureChannelInformation(page, 'before-preprod-mobile-channel-information.png', { width: 375, height: 812 }) + + await browser.close() + console.log('[capture] done ->', OUT_DIR) +} + +main().catch((error) => { + console.error(error) + process.exit(1) +}) diff --git a/scripts/capture-pr-3313-screenshots.mjs b/scripts/capture-pr-3313-screenshots.mjs new file mode 100644 index 0000000000..bedf0a553d --- /dev/null +++ b/scripts/capture-pr-3313-screenshots.mjs @@ -0,0 +1,174 @@ +import { chromium } from '@playwright/test' +import { mkdir, readdir, unlink } from 'node:fs/promises' +import path from 'node:path' + +const EMAIL = process.env.CAPGO_SCREENSHOT_EMAIL ?? 'test@capgo.app' +const PASSWORD = process.env.CAPGO_SCREENSHOT_PASSWORD ?? process.env.CAPGO_PREPROD_DEMO_PASSWORD +if (!PASSWORD) { + throw new Error('Set CAPGO_SCREENSHOT_PASSWORD or CAPGO_PREPROD_DEMO_PASSWORD before capturing AFTER screenshots') +} +const OUT_DIR = path.resolve('docs/pr-screenshots/3313') +const AFTER_BASE = process.env.CAPGO_AFTER_BASE_URL ?? 'http://127.0.0.1:5173' +const APP_ID = process.env.CAPGO_SCREENSHOT_APP_ID ?? 'com.demo.app' +const CHANNEL_ID = process.env.CAPGO_SCREENSHOT_CHANNEL_ID ?? '1' +const BUNDLE_ID = process.env.CAPGO_SCREENSHOT_BUNDLE_ID ?? '6' + +const PREPROD_SUPABASE_URL = 'https://ibwjdnhknbkcqfbabwei.supabase.co' +const PREPROD_ANON_KEY = 'sb_publishable_q_TJ5x2krpmTYIcRDknJJQ_fvKFqKYz' +const PREPROD_STORAGE_KEY = 'sb-ibwjdnhknbkcqfbabwei-auth-token' + +async function fetchPreprodSession() { + const response = await fetch(`${PREPROD_SUPABASE_URL}/auth/v1/token?grant_type=password`, { + method: 'POST', + headers: { + apikey: PREPROD_ANON_KEY, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ email: EMAIL, password: PASSWORD }), + }) + const data = await response.json() + if (!response.ok || !data.access_token) + throw new Error(`Preprod auth failed: ${data.msg || data.error_description || response.status}`) + return { + access_token: data.access_token, + refresh_token: data.refresh_token, + expires_at: Math.floor(Date.now() / 1000) + (data.expires_in ?? 3600), + expires_in: data.expires_in ?? 3600, + token_type: data.token_type ?? 'bearer', + user: data.user, + } +} + +async function installSsoBypass(page) { + const ssoOk = body => ({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(body), + }) + await page.route('**/private/sso/check-domain', async (route) => { + await route.fulfill(ssoOk({ has_sso: false, enforce_sso: false })) + }) + await page.route('**/private/sso/check-enforcement', async (route) => { + await route.fulfill(ssoOk({ allowed: true })) + }) +} + +async function login(page, baseURL) { + await installSsoBypass(page) + const session = await fetchPreprodSession() + await page.goto(`${baseURL}/login/`, { waitUntil: 'domcontentloaded', timeout: 120000 }) + await page.evaluate(({ storageKey, sessionData }) => { + localStorage.setItem(storageKey, JSON.stringify(sessionData)) + }, { storageKey: PREPROD_STORAGE_KEY, sessionData: session }) + await page.goto(`${baseURL}/apps`, { waitUntil: 'domcontentloaded', timeout: 120000 }) + await page.waitForURL(/\/(apps|dashboard|onboarding|app)(\/|$)/, { timeout: 60000 }) +} + +async function settle(page) { + await page.waitForLoadState('domcontentloaded', { timeout: 25000 }).catch(() => {}) + await page.waitForTimeout(1500) +} + +async function dismissChrome(page) { + for (let attempt = 0; attempt < 3; attempt++) { + const remind = page.getByRole('button', { name: /remind me later/i }) + if (await remind.count()) { + await remind.click({ timeout: 3000 }).catch(() => {}) + await page.waitForTimeout(500) + continue + } + break + } +} + +function channelLinkDialog(page) { + return page.locator('div.shadow-xl').filter({ has: page.locator('#dialog-v2-content') }).last() +} + +async function captureRolloutSection(page, baseURL, fileName, viewport) { + await page.setViewportSize(viewport) + await page.goto(`${baseURL}/app/${APP_ID}/channel/${CHANNEL_ID}`, { + waitUntil: 'domcontentloaded', + timeout: 120000, + }) + await settle(page) + await dismissChrome(page) + + const section = page.locator('section[aria-labelledby="rollout-settings-title"]') + await section.waitFor({ state: 'visible', timeout: 60000 }).catch(() => {}) + if (!(await section.count())) + throw new Error(`Progressive rollout section not found for ${fileName}`) + await section.scrollIntoViewIfNeeded() + await section.screenshot({ path: path.join(OUT_DIR, fileName) }) +} + +async function captureBundleAssignDialog(page, baseURL, fileName) { + await page.setViewportSize({ width: 1280, height: 900 }) + await page.goto(`${baseURL}/app/${APP_ID}/bundle/${BUNDLE_ID}`, { + waitUntil: 'domcontentloaded', + timeout: 120000, + }) + await settle(page) + await dismissChrome(page) + + const setBundleLink = page.locator('#open-channel').first() + await setBundleLink.waitFor({ state: 'visible', timeout: 60000 }).catch(() => {}) + if (!(await setBundleLink.count())) + throw new Error('Set bundle entry not found on bundle page') + await setBundleLink.click({ force: true }) + await settle(page) + + const dialog = channelLinkDialog(page) + if (!(await dialog.count())) + throw new Error('Channel link dialog not found') + + const channelRow = dialog.locator('#dialog-v2-content div.cursor-pointer').filter({ + hasText: new RegExp(`Channel id:\\s*${CHANNEL_ID}\\b`, 'i'), + }).first() + if (!(await channelRow.count())) + throw new Error(`Channel ${CHANNEL_ID} row not found in link dialog`) + await channelRow.click() + await settle(page) + + const assignOptions = ['Auto (recommended)', 'Rollout target', 'Replace stable'] + for (const label of assignOptions) { + if (!(await dialog.getByText(label, { exact: true }).count())) + throw new Error(`Bundle assign option not visible: ${label}`) + } + + await dialog.screenshot({ path: path.join(OUT_DIR, fileName) }) +} + +async function removeAfterFiles() { + await mkdir(OUT_DIR, { recursive: true }) + for (const file of await readdir(OUT_DIR)) { + if (file.startsWith('after-local-') || file.startsWith('after-preprod-')) { + await unlink(path.join(OUT_DIR, file)) + } + } +} + +async function main() { + await removeAfterFiles() + + const browser = await chromium.launch({ headless: true }) + const context = await browser.newContext({ viewport: { width: 1280, height: 900 } }) + const page = await context.newPage() + page.setDefaultTimeout(120000) + + console.log(`[capture] AFTER PR UI via ${AFTER_BASE} (serve:local + preprod Supabase auth)`) + await login(page, AFTER_BASE) + await dismissChrome(page) + + await captureRolloutSection(page, AFTER_BASE, 'after-local-desktop-rollout-section.png', { width: 1280, height: 900 }) + await captureRolloutSection(page, AFTER_BASE, 'after-local-mobile-rollout-section.png', { width: 375, height: 812 }) + await captureBundleAssignDialog(page, AFTER_BASE, 'after-local-bundle-assign-dialog.png') + + await browser.close() + console.log('[capture] done ->', OUT_DIR) +} + +main().catch((error) => { + console.error(error) + process.exit(1) +}) diff --git a/src/components/HelpTooltip.vue b/src/components/HelpTooltip.vue new file mode 100644 index 0000000000..615de3e51c --- /dev/null +++ b/src/components/HelpTooltip.vue @@ -0,0 +1,28 @@ + + + diff --git a/src/pages/app/[app].bundle.[bundle].vue b/src/pages/app/[app].bundle.[bundle].vue index 57131237a5..d7dc768e20 100644 --- a/src/pages/app/[app].bundle.[bundle].vue +++ b/src/pages/app/[app].bundle.[bundle].vue @@ -18,6 +18,7 @@ import IconSearch from '~icons/ic/round-search?raw' import IconAlertCircle from '~icons/lucide/alert-circle' import IconPencil from '~icons/lucide/pencil' import { fetchLinkedChannelsForVersion, formatLinkedChannel, unlinkLinkedChannels } from '~/services/bundleLinkedChannels' +import { buildChannelBundleAssignUpdate, buildChannelBundleUnlinkUpdate, channelHasProgressiveRollout, isBundleLinkedToChannel, resolveChannelBundleAssignTarget } from '~/services/channelBundleAssign' import { findChannelsWithoutPromotionPermission, formatChannelPromotionTargets } from '~/services/channelPromotion' import { channelUpdatePackageErrorKey } from '~/services/channelUpdatePackageError' import { formatBytes, getChecksumInfo } from '~/services/conversion' @@ -51,6 +52,11 @@ const metadataComment = ref('') // Channel chooser state const selectedChannelForLink = ref(null) +const channelAssignTarget = ref<'auto' | 'stable' | 'rollout'>('auto') + +watch(selectedChannelForLink, () => { + channelAssignTarget.value = 'auto' +}) const currentChannelAction = ref<'set' | 'open' | 'unlink' | null>(null) const channelSearchVal = ref('') const filteredChannels = ref<(Database['public']['Tables']['channels']['Row'])[]>([]) @@ -169,7 +175,7 @@ async function getChannels() { const appId = version.value.app_id const { data: dataChannel, error: channelsError } = await supabase .from('channels') - .select() + .select('*, rollout_version, rollout_enabled, version') .eq('app_id', appId) // .eq('version', version.value.id) .order('updated_at', { ascending: false }) @@ -220,7 +226,11 @@ const checksumInfo = computed(() => { }) // add check compatibility here -async function setChannel(channel: Database['public']['Tables']['channels']['Row'], id: number | null) { +async function setChannel( + channel: Database['public']['Tables']['channels']['Row'], + id: number | null, + target: 'auto' | 'stable' | 'rollout' = 'auto', +) { if (!canPromoteChannel(channel.id)) { toast.error(t('no-permission')) throw new Error('No permission') @@ -237,11 +247,28 @@ async function setChannel(channel: Database['public']['Tables']['channels']['Row throw new Error('No permission to update channel version') } + if (id === null) { + if (!version.value) + throw new Error('No bundle version loaded') + const unlinkUpdate = buildChannelBundleUnlinkUpdate(channel, version.value.id) + if (!unlinkUpdate) + throw new Error('Bundle is not linked to this channel') + return supabase + .from('channels') + .update(unlinkUpdate) + .eq('id', channel.id) + .throwOnError() + } + + const assignmentTarget = resolveChannelBundleAssignTarget(channel, target) + if (assignmentTarget === 'rollout' && !channel.version) { + toast.error(t('rollout-requires-stable-bundle')) + throw new Error('Rollout requires stable bundle') + } + return supabase .from('channels') - .update({ - version: id, - }) + .update(buildChannelBundleAssignUpdate(channel, id, target)) .eq('id', channel.id) .throwOnError() } @@ -255,6 +282,7 @@ async function ASChannelChooser() { } selectedChannelForLink.value = null + channelAssignTarget.value = 'auto' currentChannelAction.value = 'set' channelSearchVal.value = '' filteredChannels.value = getPromotableChannels() @@ -335,9 +363,10 @@ async function handleChannelLink(chan: Database['public']['Tables']['channels'][ else { toast.info(t('bundle-compatible-with-channel', { channel: chan.name })) } - await setChannel(chan, version.value.id) + const assignmentTarget = resolveChannelBundleAssignTarget(chan, channelAssignTarget.value) + await setChannel(chan, version.value.id, channelAssignTarget.value) await getChannels() - toast.success(t('linked-bundle')) + toast.success(assignmentTarget === 'rollout' ? t('rollout-target-linked') : t('linked-bundle')) toast.info(t('cloud-replication-delay')) } catch (error) { @@ -887,9 +916,9 @@ async function deleteBundle() { {{ version.min_update_version }} - +
-
+
+
+

+ {{ t('channel-bundle-assign-rollout-title') }} +

+

+ {{ t('channel-bundle-assign-rollout-hint') }} +

+
+ + + +
+
+
diff --git a/src/pages/app/[app].channel.[channel].vue b/src/pages/app/[app].channel.[channel].vue index 6e05317eac..4a57b54707 100644 --- a/src/pages/app/[app].channel.[channel].vue +++ b/src/pages/app/[app].channel.[channel].vue @@ -16,6 +16,8 @@ import IconAlertCircle from '~icons/lucide/alert-circle' import IconWarning from '~icons/lucide/alert-triangle' import IconExternalLink from '~icons/lucide/external-link' import IconDown from '~icons/material-symbols/keyboard-arrow-down-rounded' +import HelpTooltip from '~/components/HelpTooltip.vue' +import { rolloutPercentageDraftFromBps, shouldShowRolloutEnableRow, shouldShowRolloutSettings } from '~/services/channelRolloutUi' import { channelUpdatePackageErrorKey } from '~/services/channelUpdatePackageError' import { formatDate, formatLocalDate } from '~/services/date' import { checkPermissions } from '~/services/permissions' @@ -142,8 +144,9 @@ const rolloutProgressStyle = computed(() => { const percentage = Math.max(0, Math.min(100, rolloutPercentage.value)) return `width: ${percentage}%` }) -const showRolloutSettings = computed(() => !!channel.value?.rollout_enabled) -const showRolloutEnableRow = computed(() => !!channel.value && !channel.value.rollout_enabled) +const showRolloutSettings = computed(() => shouldShowRolloutSettings(channel.value?.rollout_version)) +const showRolloutEnableRow = computed(() => shouldShowRolloutEnableRow(channel.value?.rollout_version, !!channel.value?.rollout_enabled)) +const rolloutPercentageDraft = ref('0') const canUpdateChannelSettings = computedAsync(async () => { if (!packageId.value || !id.value) @@ -189,6 +192,7 @@ async function getChannel(force = false) { // Check if we already have this channel in the store if (!force && appDetailStore.currentChannelId === id.value && appDetailStore.currentChannel) { channel.value = withBuiltinChannelVersion(appDetailStore.currentChannel as any) as any + rolloutPercentageDraft.value = rolloutPercentageDraftFromBps(channel.value?.rollout_percentage_bps) if (channel.value?.name) displayStore.setChannelName(String(channel.value.id), channel.value.name) displayStore.NavTitle = channel.value?.name ?? t('channel') @@ -256,6 +260,7 @@ async function getChannel(force = false) { } channel.value = withBuiltinChannelVersion(data as any) as unknown as Database['public']['Tables']['channels']['Row'] & Channel + rolloutPercentageDraft.value = rolloutPercentageDraftFromBps(channel.value?.rollout_percentage_bps) // Store in appDetailStore appDetailStore.setChannel(id.value, channel.value) @@ -498,7 +503,6 @@ async function handleVersionLink(appVersion: Database['public']['Tables']['app_v if (bundleLinkMode.value === 'rollout') { const saved = await saveChannelChanges({ rollout_version: appVersion.id, - rollout_enabled: true, }) if (saved) { toast.success(t('rollout-target-linked')) @@ -629,23 +633,59 @@ async function enableRollout() { } async function disableRollout() { - if (await saveChannelChanges({ - rollout_enabled: false, - rollout_version: null, - rollout_paused_at: null, - rollout_pause_reason: null, - })) { - await askUpdateNotificationAfterBundleChange() - } + await confirmRolloutAction( + t('rollout-disable-confirm-title'), + t('rollout-disable-confirm-description', { + stable: stableBundleName.value, + target: rolloutTargetName.value, + }), + t('rollout-disable-confirm-action'), + async () => { + if (await saveChannelChanges({ + rollout_enabled: false, + rollout_version: null, + rollout_paused_at: null, + rollout_pause_reason: null, + })) { + await askUpdateNotificationAfterBundleChange() + } + }, + ) } -async function saveRolloutPercentage(value: string) { - const percentage = Number.parseFloat(value) +async function saveRolloutPercentage(value?: string) { + const percentage = Number.parseFloat(value ?? rolloutPercentageDraft.value) if (Number.isNaN(percentage) || percentage < 0 || percentage > 100) { toast.error(t('invalid-rollout-percentage')) return } - await saveChannelChange('rollout_percentage_bps', Math.round(percentage * 100) as any) + const saved = await saveChannelChange('rollout_percentage_bps', Math.round(percentage * 100) as any) + if (saved) + rolloutPercentageDraft.value = String(percentage) +} + +async function confirmRolloutAction( + title: string, + description: string, + confirmText: string, + onConfirm: () => Promise, +) { + dialogStore.openDialog({ + title, + description, + buttons: [ + { + text: t('button-cancel'), + role: 'cancel', + }, + { + text: confirmText, + role: 'primary', + handler: onConfirm, + }, + ], + }) + await dialogStore.onDialogDismiss() } async function saveIntegerField(key: EditableChannelKey, value: string, min: number, max: number, nullable = false) { @@ -679,36 +719,67 @@ async function saveAutoPauseConfidence(value: string) { } async function rollbackRollout() { - if (await saveChannelChanges({ - rollout_version: null, - rollout_enabled: false, - rollout_percentage_bps: 0, - rollout_paused_at: null, - rollout_pause_reason: null, - })) { - await askUpdateNotificationAfterBundleChange() - } + await confirmRolloutAction( + t('rollout-rollback-confirm-title'), + t('rollout-rollback-confirm-description', { + stable: stableBundleName.value, + target: rolloutTargetName.value, + }), + t('rollout-rollback-confirm-action'), + async () => { + if (await saveChannelChanges({ + rollout_version: null, + rollout_enabled: false, + rollout_percentage_bps: 0, + rollout_paused_at: null, + rollout_pause_reason: null, + })) { + await askUpdateNotificationAfterBundleChange() + } + }, + ) } async function promoteRollout() { if (!channel.value?.rollout_version) return - if (await saveChannelChanges({ - version: channel.value.rollout_version, - rollout_version: null, - rollout_enabled: false, - rollout_percentage_bps: 0, - rollout_paused_at: null, - rollout_pause_reason: null, - })) { - await askUpdateNotificationAfterBundleChange() - } + await confirmRolloutAction( + t('rollout-promote-confirm-title'), + t('rollout-promote-confirm-description', { + stable: stableBundleName.value, + target: rolloutTargetName.value, + percent: rolloutPercentageText.value, + }), + t('rollout-promote-confirm-action'), + async () => { + if (await saveChannelChanges({ + version: channel.value!.rollout_version, + rollout_version: null, + rollout_enabled: false, + rollout_percentage_bps: 0, + rollout_paused_at: null, + rollout_pause_reason: null, + })) { + await askUpdateNotificationAfterBundleChange() + } + }, + ) } async function toggleRolloutPause() { - await saveChannelChanges(channel.value?.rollout_paused_at - ? { rollout_paused_at: null, rollout_pause_reason: null } - : { rollout_paused_at: new Date().toISOString(), rollout_pause_reason: t('manual-rollout-pause') }) + const isPaused = !!channel.value?.rollout_paused_at + await confirmRolloutAction( + isPaused ? t('rollout-resume-confirm-title') : t('rollout-pause-confirm-title'), + isPaused + ? t('rollout-resume-confirm-description', { target: rolloutTargetName.value, percent: rolloutPercentageText.value }) + : t('rollout-pause-confirm-description', { stable: stableBundleName.value, target: rolloutTargetName.value }), + isPaused ? t('resume') : t('pause'), + async () => { + await saveChannelChanges(isPaused + ? { rollout_paused_at: null, rollout_pause_reason: null } + : { rollout_paused_at: new Date().toISOString(), rollout_pause_reason: t('manual-rollout-pause') }) + }, + ) } async function refreshFilteredVersions() { @@ -1034,8 +1105,8 @@ async function copyCurlCommand() {
{{ channel.version.name }}
-
- -
diff --git a/src/services/channelBundleAssign.ts b/src/services/channelBundleAssign.ts new file mode 100644 index 0000000000..2d8e8d3f76 --- /dev/null +++ b/src/services/channelBundleAssign.ts @@ -0,0 +1,66 @@ +import type { Database } from '~/types/supabase.types' + +export type ChannelBundleAssignTarget = 'auto' | 'stable' | 'rollout' + +type ChannelRolloutState = Pick< + Database['public']['Tables']['channels']['Row'], + 'rollout_enabled' | 'rollout_version' | 'version' +> + +export function channelHasProgressiveRollout(channel: Pick) { + return channel.rollout_enabled || channel.rollout_version != null +} + +export function resolveChannelBundleAssignTarget( + channel: ChannelRolloutState, + requestedTarget: ChannelBundleAssignTarget = 'auto', +): 'stable' | 'rollout' { + if (requestedTarget === 'stable') + return 'stable' + if (requestedTarget === 'rollout') + return 'rollout' + return channelHasProgressiveRollout(channel) ? 'rollout' : 'stable' +} + +export function isBundleLinkedToChannel( + channel: Pick, + versionId: number, +) { + return channel.version === versionId || channel.rollout_version === versionId +} + +export function buildChannelBundleUnlinkUpdate( + channel: Pick, + versionId: number, +): Database['public']['Tables']['channels']['Update'] | null { + if (channel.rollout_version === versionId) { + return { + rollout_version: null, + rollout_enabled: false, + rollout_percentage_bps: 0, + rollout_paused_at: null, + rollout_pause_reason: null, + } + } + if (channel.version === versionId) + return { version: null } + return null +} + +export function buildChannelBundleAssignUpdate( + channel: ChannelRolloutState, + versionId: number, + requestedTarget: ChannelBundleAssignTarget = 'auto', +): Database['public']['Tables']['channels']['Update'] { + const assignmentTarget = resolveChannelBundleAssignTarget(channel, requestedTarget) + + if (assignmentTarget === 'rollout') { + return { + rollout_version: versionId, + } + } + + return { + version: versionId, + } +} diff --git a/src/services/channelRolloutUi.ts b/src/services/channelRolloutUi.ts new file mode 100644 index 0000000000..b99e0131c4 --- /dev/null +++ b/src/services/channelRolloutUi.ts @@ -0,0 +1,14 @@ +export function shouldShowRolloutSettings(rolloutVersion: number | null | undefined) { + return rolloutVersion != null +} + +export function shouldShowRolloutEnableRow( + rolloutVersion: number | null | undefined, + rolloutEnabled: boolean, +) { + return rolloutVersion == null && !rolloutEnabled +} + +export function rolloutPercentageDraftFromBps(rolloutPercentageBps: number | null | undefined) { + return String((rolloutPercentageBps ?? 0) / 100) +} diff --git a/supabase/functions/_backend/public/bundle/set_channel.ts b/supabase/functions/_backend/public/bundle/set_channel.ts index cfa36f4e11..8a80907515 100644 --- a/supabase/functions/_backend/public/bundle/set_channel.ts +++ b/supabase/functions/_backend/public/bundle/set_channel.ts @@ -8,10 +8,36 @@ import { closeClient, getDrizzleClient, getPgClient, logPgError } from '../../ut import { checkPermissionPg } from '../../utils/rbac.ts' import { isValidAppId } from '../../utils/utils.ts' +export type SetChannelTarget = 'auto' | 'stable' | 'rollout' + export interface SetChannelBody { app_id: string version_id: number channel_id: number + target?: SetChannelTarget +} + +interface ChannelRow { + name: string + owner_org: string + version: number | null + rollout_version: number | null + rollout_enabled: boolean +} + +export function channelHasProgressiveRollout(channel: Pick) { + return channel.rollout_enabled || channel.rollout_version != null +} + +export function resolveSetChannelTarget( + channel: Pick, + requestedTarget: SetChannelTarget = 'auto', +): 'stable' | 'rollout' { + if (requestedTarget === 'stable') + return 'stable' + if (requestedTarget === 'rollout') + return 'rollout' + return channelHasProgressiveRollout(channel) ? 'rollout' : 'stable' } export interface PgQueryClient { @@ -19,11 +45,11 @@ export interface PgQueryClient { release: () => void } -interface ChannelRow { name: string, owner_org: string } export interface SetChannelResult { channelName: string versionName: string + assignmentTarget: 'stable' | 'rollout' } type DrizzleClient = ReturnType @@ -36,6 +62,10 @@ function validateSetChannelBody(body: SetChannelBody) { if (!isValidAppId(body.app_id)) { throw simpleError('invalid_app_id', 'App ID must be a reverse domain string', { app_id: body.app_id }) } + + if (body.target != null && body.target !== 'auto' && body.target !== 'stable' && body.target !== 'rollout') { + throw simpleError('invalid_target', 'Invalid channel assignment target', { target: body.target }) + } } function getEffectiveApikey(c: Context, apikey: Database['public']['Tables']['apikeys']['Row']) { @@ -48,7 +78,11 @@ function getEffectiveApikey(c: Context, apikey: Database async function fetchTargetChannel(dbClient: PgQueryClient, body: SetChannelBody) { const channelResult = await dbClient.query( - `SELECT name, owner_org + `SELECT name, + owner_org, + version, + rollout_version, + rollout_enabled FROM public.channels WHERE id = $1 AND app_id = $2 @@ -91,6 +125,22 @@ async function updateChannelVersion(dbClient: PgQueryClient, body: SetChannelBod } } +async function updateChannelRolloutVersion(dbClient: PgQueryClient, body: SetChannelBody, channelOwnerOrg: string) { + const updateResult = await dbClient.query( + `UPDATE public.channels + SET rollout_version = $1 + WHERE id = $2 + AND app_id = $3 + AND owner_org = $4 + RETURNING id`, + [body.version_id, body.channel_id, body.app_id, channelOwnerOrg], + ) + + if ((updateResult.rowCount ?? 0) !== 1) { + throw new Error('Channel rollout update affected 0 rows') + } +} + export async function assertCanPromoteChannelInTransaction( c: Context, body: SetChannelBody, @@ -136,12 +186,29 @@ export async function setChannelInTransaction( throw simpleError('cannot_find_channel', 'Cannot find channel') } + const assignmentTarget = resolveSetChannelTarget(channel, body.target) + if (assignmentTarget === 'rollout' && !channel.version) { + throw simpleError( + 'cannot_set_rollout_without_stable', + 'Cannot set rollout target because this channel has no stable bundle yet', + { channel_id: body.channel_id }, + ) + } + await dbClient.query( 'SELECT set_config(\'request.headers\', $1, true)', [JSON.stringify({ capgkey: getEffectiveApikey(c, apikey) })], ) - await updateChannelVersion(dbClient, body, channel.owner_org) - return { channelName: channel.name, versionName } + if (assignmentTarget === 'rollout') + await updateChannelRolloutVersion(dbClient, body, channel.owner_org) + else + await updateChannelVersion(dbClient, body, channel.owner_org) + + return { + channelName: channel.name, + versionName, + assignmentTarget, + } } export async function setChannel(c: Context, body: SetChannelBody, apikey: Database['public']['Tables']['apikeys']['Row']): Promise { @@ -176,8 +243,13 @@ export async function setChannel(c: Context, body: SetCh await closeClient(c, pgClient) } + const message = result!.assignmentTarget === 'rollout' + ? `Bundle ${result!.versionName} set as rollout target on channel ${result!.channelName}` + : `Bundle ${result!.versionName} set to channel ${result!.channelName}` + return c.json({ status: 'success', - message: `Bundle ${result!.versionName} set to channel ${result!.channelName}`, + message, + assignmentTarget: result!.assignmentTarget, }) } diff --git a/tests/bundle-set-channel-rollout.unit.test.ts b/tests/bundle-set-channel-rollout.unit.test.ts new file mode 100644 index 0000000000..43c157a6a6 --- /dev/null +++ b/tests/bundle-set-channel-rollout.unit.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest' +import { + channelHasProgressiveRollout, + resolveSetChannelTarget, +} from '../supabase/functions/_backend/public/bundle/set_channel.ts' + +describe('bundle set channel rollout targeting', () => { + it('detects progressive rollout configuration', () => { + expect(channelHasProgressiveRollout({ rollout_enabled: false, rollout_version: null })).toBe(false) + expect(channelHasProgressiveRollout({ rollout_enabled: true, rollout_version: null })).toBe(true) + expect(channelHasProgressiveRollout({ rollout_enabled: false, rollout_version: 12 })).toBe(true) + }) + + it('defaults to rollout when progressive rollout is configured', () => { + expect(resolveSetChannelTarget({ rollout_enabled: true, rollout_version: 12 }, 'auto')).toBe('rollout') + expect(resolveSetChannelTarget({ rollout_enabled: false, rollout_version: null }, 'auto')).toBe('stable') + }) + + it('honors explicit stable and rollout targets', () => { + expect(resolveSetChannelTarget({ rollout_enabled: true, rollout_version: 12 }, 'stable')).toBe('stable') + expect(resolveSetChannelTarget({ rollout_enabled: false, rollout_version: null }, 'rollout')).toBe('rollout') + }) +}) diff --git a/tests/bundle.test.ts b/tests/bundle.test.ts index 2c9da196bf..6304820671 100644 --- a/tests/bundle.test.ts +++ b/tests/bundle.test.ts @@ -7,7 +7,7 @@ const APPNAME = `com.app.b.${id}` const RBAC_APPNAME = `com.app.b.rbac.${id}` const RBAC_ORG_ID = randomUUID() -async function putBundleToChannel(body: { app_id: string, version_id: number, channel_id: number }): Promise { +async function putBundleToChannel(body: { app_id: string, version_id: number, channel_id: number, target?: 'auto' | 'stable' | 'rollout' }): Promise { return fetch(`${BASE_URL}/bundle`, { method: 'PUT', headers, @@ -311,11 +311,96 @@ describe('[PUT] /bundle operations - Set bundle to channel', () => { } }) - it('should reset leftover rollout when a new stable bundle is set', async () => { + it('should assign uploads to rollout target when progressive rollout is configured', async () => { const supabase = getSupabaseClient() - const nextVersion = await createAppVersions('1.0.1-test-channel-rollout-reset', APPNAME) + const nextVersion = await createAppVersions('1.0.1-test-channel-rollout-assign', APPNAME) const rolloutVersion = await createAppVersions('1.0.0-test-channel-rollout-target', APPNAME) + const { error: leftoverError } = await supabase + .from('channels') + .update({ + version: versionId, + rollout_version: rolloutVersion.id, + rollout_enabled: true, + rollout_percentage_bps: 2500, + }) + .eq('id', channelId) + .eq('app_id', APPNAME) + expect(leftoverError).toBeNull() + + const response = await putBundleToChannel({ + app_id: APPNAME, + version_id: nextVersion.id, + channel_id: channelId, + }) + expect(response.status).toBe(200) + + const { data: after, error: afterError } = await supabase + .from('channels') + .select('version, rollout_version, rollout_enabled, rollout_percentage_bps, rollout_id') + .eq('id', channelId) + .single() + expect(afterError).toBeNull() + expect(after?.version).toBe(versionId) + expect(after?.rollout_version).toBe(nextVersion.id) + expect(after?.rollout_enabled).toBe(true) + expect(after?.rollout_percentage_bps).toBe(2500) + }) + + it('should reject invalid channel assignment targets', async () => { + const response = await fetch(`${BASE_URL}/bundle`, { + method: 'PUT', + headers, + body: JSON.stringify({ + app_id: APPNAME, + version_id: versionId, + channel_id: channelId, + target: 'invalid', + }), + }) + + expect(response.status).toBe(400) + }) + + it('should preserve rollout_enabled when assigning a rollout target', async () => { + const supabase = getSupabaseClient() + const nextVersion = await createAppVersions('1.0.3-test-rollout-disabled-assign', APPNAME) + + const { error: setupError } = await supabase + .from('channels') + .update({ + version: versionId, + rollout_version: null, + rollout_enabled: false, + rollout_percentage_bps: 0, + }) + .eq('id', channelId) + .eq('app_id', APPNAME) + expect(setupError).toBeNull() + + const response = await putBundleToChannel({ + app_id: APPNAME, + version_id: nextVersion.id, + channel_id: channelId, + target: 'rollout', + }) + expect(response.status).toBe(200) + + const { data: after, error: afterError } = await supabase + .from('channels') + .select('rollout_version, rollout_enabled') + .eq('id', channelId) + .single() + expect(afterError).toBeNull() + expect(after?.rollout_version).toBe(nextVersion.id) + expect(after?.rollout_enabled).toBe(false) + }) + + it('should reset leftover rollout when a new stable bundle is set explicitly', async () => { + const supabase = getSupabaseClient() + const nextVersion = await createAppVersions('1.0.2-test-channel-rollout-reset', APPNAME) + const rolloutVersion = await createAppVersions('1.0.0-test-channel-rollout-target-2', APPNAME) + const { error: leftoverError } = await supabase .from('channels') .update({ @@ -342,6 +427,7 @@ describe('[PUT] /bundle operations - Set bundle to channel', () => { app_id: APPNAME, version_id: nextVersion.id, channel_id: channelId, + target: 'stable', }) expect(response.status).toBe(200) diff --git a/tests/channel-bundle-assign.unit.test.ts b/tests/channel-bundle-assign.unit.test.ts new file mode 100644 index 0000000000..e6b36f8525 --- /dev/null +++ b/tests/channel-bundle-assign.unit.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest' +import { + buildChannelBundleAssignUpdate, + buildChannelBundleUnlinkUpdate, + isBundleLinkedToChannel, + resolveChannelBundleAssignTarget, +} from '../src/services/channelBundleAssign.ts' + +describe('channel bundle assign helpers', () => { + it('defaults to rollout when progressive rollout is configured', () => { + expect(resolveChannelBundleAssignTarget({ + rollout_enabled: true, + rollout_version: 10, + version: 5, + }, 'auto')).toBe('rollout') + }) + + it('builds rollout updates without touching stable or rollout_enabled', () => { + expect(buildChannelBundleAssignUpdate({ + rollout_enabled: true, + rollout_version: 10, + version: 5, + }, 99, 'auto')).toEqual({ + rollout_version: 99, + }) + expect(buildChannelBundleAssignUpdate({ + rollout_enabled: false, + rollout_version: 10, + version: 5, + }, 99, 'rollout')).toEqual({ + rollout_version: 99, + }) + }) + + it('builds stable updates when requested', () => { + expect(buildChannelBundleAssignUpdate({ + rollout_enabled: true, + rollout_version: 10, + version: 5, + }, 99, 'stable')).toEqual({ + version: 99, + }) + }) + + it('detects stable and rollout bundle associations', () => { + expect(isBundleLinkedToChannel({ version: 5, rollout_version: null }, 5)).toBe(true) + expect(isBundleLinkedToChannel({ version: 5, rollout_version: 10 }, 10)).toBe(true) + expect(isBundleLinkedToChannel({ version: 5, rollout_version: 10 }, 99)).toBe(false) + }) + + it('unlinks the matching stable or rollout field', () => { + expect(buildChannelBundleUnlinkUpdate({ version: 5, rollout_version: 10 }, 10)).toEqual({ + rollout_version: null, + rollout_enabled: false, + rollout_percentage_bps: 0, + rollout_paused_at: null, + rollout_pause_reason: null, + }) + expect(buildChannelBundleUnlinkUpdate({ version: 5, rollout_version: 10 }, 5)).toEqual({ + version: null, + }) + expect(buildChannelBundleUnlinkUpdate({ version: 5, rollout_version: 10 }, 99)).toBeNull() + }) +}) diff --git a/tests/channel-rollout-ui.unit.test.ts b/tests/channel-rollout-ui.unit.test.ts new file mode 100644 index 0000000000..8af9d09612 --- /dev/null +++ b/tests/channel-rollout-ui.unit.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest' +import { + rolloutPercentageDraftFromBps, + shouldShowRolloutEnableRow, + shouldShowRolloutSettings, +} from '../src/services/channelRolloutUi.ts' + +describe('channel rollout UI helpers', () => { + it('shows rollout settings when a rollout target exists', () => { + expect(shouldShowRolloutSettings(42)).toBe(true) + expect(shouldShowRolloutSettings(null)).toBe(false) + }) + + it('hides the enable row when a rollout target already exists', () => { + expect(shouldShowRolloutEnableRow(42, false)).toBe(false) + expect(shouldShowRolloutEnableRow(null, false)).toBe(true) + expect(shouldShowRolloutEnableRow(null, true)).toBe(false) + }) + + it('initializes rollout percentage draft from basis points', () => { + expect(rolloutPercentageDraftFromBps(2500)).toBe('25') + expect(rolloutPercentageDraftFromBps(null)).toBe('0') + }) +}) diff --git a/tests/cli-preview-lifecycle.test.ts b/tests/cli-preview-lifecycle.test.ts index dfd8a44e2e..d3256e74d3 100644 --- a/tests/cli-preview-lifecycle.test.ts +++ b/tests/cli-preview-lifecycle.test.ts @@ -6,11 +6,13 @@ import { BASE_URL, createIsolatedSeedAppOptions, executeSQL, + fetchTestRequest, getAuthHeaders, resetAndSeedAppData, resetAppData, SUPABASE_ANON_KEY, SUPABASE_BASE_URL, + warmEdgeEndpoint, } from './test-utils.ts' vi.mock('../cli/src/utils', async (importOriginal) => { @@ -104,7 +106,7 @@ let authHeaders: Record const apiKeyIds: number[] = [] async function createAppApiKey(name: string, roleName = 'app_preview'): Promise { - const createResponse = await fetch(`${BASE_URL}/apikey`, { + const createResponse = await fetchTestRequest(`${BASE_URL}/apikey`, { method: 'POST', headers: authHeaders, body: JSON.stringify({ @@ -123,14 +125,17 @@ async function createAppApiKey(name: string, roleName = 'app_preview'): Promise< beforeAll(async () => { authHeaders = await getAuthHeaders() await resetAndSeedAppData(APPNAME, seedOptions) + // Load the apikey isolate before concurrent preview-lifecycle POSTs (CI cold start). + await warmEdgeEndpoint('/apikey', { method: 'GET', headers: authHeaders }) }) afterAll(async () => { try { for (const apiKeyId of apiKeyIds) { - const deleteResponse = await fetch(`${BASE_URL}/apikey/${apiKeyId}`, { + const deleteResponse = await fetchTestRequest(`${BASE_URL}/apikey/${apiKeyId}`, { method: 'DELETE', headers: authHeaders, + retryUnsafe: true, }) expect(deleteResponse.status).toBe(200) }