// @vitest-environment happy-dom import type { App } from 'vue' import type { ChannelRolloutConfirmFlowsDeps } from '../src/utils/channelRolloutConfirmFlows' import { createPinia, setActivePinia } from 'pinia' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createApp, defineComponent, h, nextTick } from 'vue' import { createI18n } from 'vue-i18n' import en from '../messages/en.json' import { useDialogV2Store } from '../src/stores/dialogv2' import { createChannelRolloutConfirmFlows, formatRolloutCacheTtlDisplay, formatRolloutCacheTtlHuman, isRolloutPercentageDraftChanged } from '../src/utils/channelRolloutConfirmFlows' import { getUpdatePackageDescription, getUpdatePackageInfoDescription } from '../src/utils/channelUpdatePackageCopy' import { confirmConsequentialChannelChange } from '../src/utils/confirmConsequentialChannelChange' const DialogHarness = defineComponent({ name: 'DialogHarness', setup() { const dialogStore = useDialogV2Store() return () => { if (!dialogStore.showDialog) return null const options = dialogStore.dialogOptions return h('div', { 'data-testid': 'dialog-shell' }, [ h('h3', options.title ?? ''), h('p', options.description ?? ''), ...(options.buttons ?? []).map(button => h('button', { type: 'button', disabled: button.disabled, onClick: () => dialogStore.closeDialog(button), }, button.text), ), ]) } }, }) const mountedApps: App[] = [] function mountDialogShell() { const pinia = createPinia() setActivePinia(pinia) const i18n = createI18n({ legacy: false, locale: 'en', messages: { en }, }) const container = document.createElement('div') document.body.appendChild(container) const app = createApp(DialogHarness) app.use(pinia) app.use(i18n) app.mount(container) mountedApps.push(app) return { container, dialogStore: useDialogV2Store(), t: i18n.global.t as (key: string, params?: Record) => string } } function findDialogButton(container: HTMLElement, label: string) { const buttons = Array.from(container.querySelectorAll('button')) const match = buttons.find(button => button.textContent?.trim() === label) if (!match) throw new Error(`Dialog button not found: ${label}`) return match } async function dismissDialog(container: HTMLElement, label: 'Cancel' | 'Confirm' | 'Revert') { findDialogButton(container, label).click() await nextTick() await nextTick() } interface TestChannel { update_package?: 'all' | 'zip' | 'delta' | 'zip_from_builtin' | 'delta_from_builtin' | null rollout_version?: number | null rollout_percentage_bps?: number | null rollout_cache_ttl_seconds?: number | null rollout_paused_at?: string | null } function createTestFlows(options: { container: HTMLElement t: (key: string, params?: Record) => string channel: TestChannel saveChannelChange?: ReturnType saveChannelChanges?: ReturnType askUpdateNotificationAfterBundleChange?: ReturnType canUpdate?: boolean }) { const dialogStore = useDialogV2Store() const saveChannelChange = options.saveChannelChange ?? vi.fn(async () => true) const saveChannelChanges = options.saveChannelChanges ?? vi.fn(async () => true) const askUpdateNotificationAfterBundleChange = options.askUpdateNotificationAfterBundleChange ?? vi.fn(async () => {}) const openSelectRolloutVersion = vi.fn(async () => {}) const closeUpdatePackageDropdown = vi.fn() const toast = { error: vi.fn(), info: vi.fn() } const flows = createChannelRolloutConfirmFlows({ dialogStore, t: options.t, toast, canUpdateChannelSettings: () => options.canUpdate ?? true, closeUpdatePackageDropdown, getChannel: () => options.channel, rolloutTargetName: () => 'Rollout bundle', stableBundleName: () => 'Stable bundle', rolloutPercentageText: () => '10%', saveChannelChange: saveChannelChange as ChannelRolloutConfirmFlowsDeps['saveChannelChange'], saveChannelChanges: saveChannelChanges as ChannelRolloutConfirmFlowsDeps['saveChannelChanges'], askUpdateNotificationAfterBundleChange: askUpdateNotificationAfterBundleChange as ChannelRolloutConfirmFlowsDeps['askUpdateNotificationAfterBundleChange'], openSelectRolloutVersion, }) return { flows, saveChannelChange, saveChannelChanges, askUpdateNotificationAfterBundleChange, toast, openSelectRolloutVersion, dismiss: (label: 'Cancel' | 'Confirm' | 'Revert') => dismissDialog(options.container, label), } } beforeEach(() => { document.body.innerHTML = '' }) afterEach(() => { mountedApps.splice(0).forEach(app => app.unmount()) }) describe('channel information rollout and update package UX', () => { it('confirmConsequentialChannelChange blocks cancel while confirm is in flight', async () => { const { container, dialogStore, t } = mountDialogShell() const onConfirm = vi.fn(async () => { await new Promise(resolve => setTimeout(resolve, 40)) }) void confirmConsequentialChannelChange( dialogStore, { cancel: t('button-cancel'), confirm: t('button-confirm') }, { id: 'confirm-rollout-percentage', title: t('confirm-rollout-percentage-title'), description: t('confirm-rollout-percentage-description', { current: '10%', next: '25%' }), onConfirm, }, ) await nextTick() findDialogButton(container, 'Confirm').click() await nextTick() findDialogButton(container, 'Cancel').click() await nextTick() expect(dialogStore.showDialog).toBe(true) await new Promise(resolve => setTimeout(resolve, 60)) expect(onConfirm).toHaveBeenCalledTimes(1) expect(dialogStore.showDialog).toBe(false) }) it('confirmConsequentialChannelChange ignores duplicate confirm clicks', async () => { const { container, dialogStore, t } = mountDialogShell() const onConfirm = vi.fn(async () => { await new Promise(resolve => setTimeout(resolve, 30)) }) void confirmConsequentialChannelChange( dialogStore, { cancel: t('button-cancel'), confirm: t('button-confirm') }, { id: 'confirm-promote-rollout', title: t('confirm-promote-rollout-title'), description: t('confirm-promote-rollout-description', { target: 'v2.0.0' }), onConfirm, }, ) await nextTick() const confirmButton = findDialogButton(container, 'Confirm') confirmButton.click() confirmButton.click() await dismissDialog(container, 'Confirm') expect(onConfirm).toHaveBeenCalledTimes(1) }) it('promoteRollout second confirm after save does not unlink the stable bundle', async () => { const { container, dialogStore, t } = mountDialogShell() const channel: TestChannel = { rollout_version: 202 } const writes: Record[] = [] let releaseNotification: () => void = () => {} const notificationGate = new Promise((resolve) => { releaseNotification = resolve }) const saveChannelChanges = vi.fn(async (changes: Record) => { writes.push({ ...changes }) if (Object.prototype.hasOwnProperty.call(changes, 'rollout_version')) channel.rollout_version = changes.rollout_version as number | null return true }) const { flows } = createTestFlows({ container, t, channel, saveChannelChanges, askUpdateNotificationAfterBundleChange: vi.fn(() => notificationGate), }) const promote = flows.promoteRollout() await nextTick() findDialogButton(container, 'Confirm').click() await vi.waitFor(() => expect(saveChannelChanges).toHaveBeenCalledTimes(1)) expect(writes[0]).toEqual(expect.objectContaining({ version: 202, rollout_version: null })) expect(channel.rollout_version).toBeNull() await nextTick() expect(findDialogButton(container, 'Confirm').disabled).toBe(true) findDialogButton(container, 'Confirm').click() const enabledConfirm = dialogStore.dialogOptions.buttons?.find(button => button.role === 'primary') expect(enabledConfirm).toBeDefined() await dialogStore.closeDialog({ text: 'Confirm', role: 'primary', handler: enabledConfirm?.handler, }) await dialogStore.closeDialog(enabledConfirm) releaseNotification() await promote expect(saveChannelChanges).toHaveBeenCalledTimes(1) expect(writes).toEqual([expect.objectContaining({ version: 202, rollout_version: null })]) }) it('onSelectUpdatePackage cancel skips save and confirm applies update_package', async () => { const { container, t } = mountDialogShell() const channel: TestChannel = { update_package: 'all' } const { flows, saveChannelChange, dismiss } = createTestFlows({ container, t, channel }) const firstSelect = flows.onSelectUpdatePackage('delta') await nextTick() expect(container.textContent).toContain('Change download format?') await dismiss('Cancel') await firstSelect expect(saveChannelChange).not.toHaveBeenCalled() const secondSelect = flows.onSelectUpdatePackage('delta') await nextTick() await dismiss('Confirm') await secondSelect expect(saveChannelChange).toHaveBeenCalledWith('update_package', 'delta') }) it('empty rollout percentage draft is unchanged while invalid and out-of-range stay dirty', () => { expect(isRolloutPercentageDraftChanged('', 1000)).toBe(false) expect(isRolloutPercentageDraftChanged(' ', 1000)).toBe(false) expect(isRolloutPercentageDraftChanged('10', 1000)).toBe(false) expect(isRolloutPercentageDraftChanged('abc', 1000)).toBe(true) expect(isRolloutPercentageDraftChanged('25', 1000)).toBe(true) expect(isRolloutPercentageDraftChanged('150', 1000)).toBe(true) }) it('number drafts from type=number v-model do not throw and compare in bps', () => { expect(isRolloutPercentageDraftChanged(10, 1000)).toBe(false) expect(isRolloutPercentageDraftChanged(1, 1000)).toBe(true) expect(isRolloutPercentageDraftChanged(25, 2500)).toBe(false) expect(isRolloutPercentageDraftChanged(0, 0)).toBe(false) }) it('applyRolloutPercentage cancel skips save and confirm updates bps', async () => { const { container, t } = mountDialogShell() const channel: TestChannel = { rollout_percentage_bps: 1000 } const { flows, saveChannelChange, saveChannelChanges, dismiss } = createTestFlows({ container, t, channel }) const firstApply = flows.applyRolloutPercentage('25') await nextTick() expect(container.textContent).toContain('Apply rollout percentage?') await dismiss('Cancel') await firstApply expect(saveChannelChange).not.toHaveBeenCalled() expect(saveChannelChanges).not.toHaveBeenCalled() const secondApply = flows.applyRolloutPercentage('25') await nextTick() await dismiss('Confirm') await secondApply expect(saveChannelChanges).toHaveBeenCalledWith({ rollout_percentage_bps: 2500 }) const numericApply = flows.applyRolloutPercentage(40) await nextTick() await dismiss('Confirm') await numericApply expect(saveChannelChanges).toHaveBeenCalledWith({ rollout_percentage_bps: 4000 }) }) it('applyRolloutSettings confirms percentage and cache TTL together', async () => { const { container, t } = mountDialogShell() const channel: TestChannel = { rollout_percentage_bps: 1000, rollout_cache_ttl_seconds: 2592000 } const { flows, saveChannelChanges, dismiss } = createTestFlows({ container, t, channel }) const apply = flows.applyRolloutSettings({ percentage: 25, cacheTtlSeconds: 3600 }) await nextTick() expect(container.textContent).toContain('Update rollout settings?') expect(container.textContent).toContain('1 hour (3600s)') await dismiss('Confirm') await apply expect(saveChannelChanges).toHaveBeenCalledWith({ rollout_percentage_bps: 2500, rollout_cache_ttl_seconds: 3600, }) }) it('applyRolloutSettings rejects an out-of-range cache TTL', async () => { const { container, t } = mountDialogShell() const channel: TestChannel = { rollout_percentage_bps: 1000, rollout_cache_ttl_seconds: 2592000 } const { flows, saveChannelChanges, toast } = createTestFlows({ container, t, channel }) await flows.applyRolloutSettings({ percentage: 25, cacheTtlSeconds: 30 }) expect(toast.error).toHaveBeenCalledWith(t('invalid-rollout-cache-ttl')) expect(saveChannelChanges).not.toHaveBeenCalled() expect(container.textContent).not.toContain('Update rollout settings?') }) it('rollout control confirms match expected dialog ids and saves', async () => { const { container, t } = mountDialogShell() const channel: TestChannel = { rollout_version: 42, rollout_percentage_bps: 500, rollout_paused_at: null, } const saveChannelChanges = vi.fn(async () => true) const { flows, saveChannelChange, dismiss } = createTestFlows({ container, t, channel, saveChannelChanges, }) const cases = [ { run: () => flows.enableRollout(), title: 'Enable progressive rollout?', confirm: 'Confirm', assert: () => expect(saveChannelChange).toHaveBeenCalledWith('rollout_enabled', true), }, { run: () => flows.rollbackRollout(), title: 'Revert rollout to Stable bundle?', confirm: 'Revert', assert: () => expect(saveChannelChanges).toHaveBeenCalledWith(expect.objectContaining({ rollout_percentage_bps: 0 })), }, { run: () => flows.promoteRollout(), title: 'Promote to 100% of devices?', confirm: 'Confirm', assert: () => expect(saveChannelChanges).toHaveBeenCalledWith(expect.objectContaining({ version: 42 })), }, { run: () => flows.toggleRolloutPause(), title: 'Pause rollout?', confirm: 'Confirm', assert: () => expect(saveChannelChanges).toHaveBeenCalledWith(expect.objectContaining({ rollout_paused_at: expect.any(String) })), }, ] as const for (const testCase of cases) { saveChannelChange.mockClear() saveChannelChanges.mockClear() const cancelFlow = testCase.run() await nextTick() expect(container.textContent).toContain(testCase.title) await dismiss('Cancel') await cancelFlow expect(saveChannelChange).not.toHaveBeenCalled() expect(saveChannelChanges).not.toHaveBeenCalled() const confirmFlow = testCase.run() await nextTick() await dismiss(testCase.confirm) await confirmFlow testCase.assert() } }) it('rollout settings info dialog shows help copy without confirm actions', async () => { const { container, dialogStore, t } = mountDialogShell() dialogStore.openDialog({ id: 'rollout-settings-info', title: t('progressive-rollout'), description: `${t('rollout-settings-help')}\n\n${t('rollout-percentage-help')}\n\n${t('cache-ttl-help')}`, buttons: [{ text: t('close'), role: 'primary' }], }) await nextTick() expect(container.textContent).toContain('Progressive rollout') expect(container.textContent).toContain('Stable fallback stays for most devices') expect(container.textContent).toContain('Share of eligible devices in the sticky rollout cohort') expect(container.textContent).toContain('computed from each device ID') expect(container.textContent).not.toContain('Confirm') findDialogButton(container, 'Close').click() await nextTick() expect(dialogStore.showDialog).toBe(false) }) it('update package info dialog shows current format help without confirm actions', async () => { const { container, dialogStore, t } = mountDialogShell() dialogStore.openDialog({ id: 'update-package-info', title: t('update-package'), description: getUpdatePackageInfoDescription(t, 'all'), buttons: [{ text: t('close'), role: 'primary' }], }) await nextTick() expect(container.textContent).toContain('Download format') expect(container.textContent).toContain('Controls what each device downloads on update check') expect(container.textContent).toContain('Zip + delta (default)') expect(container.textContent).toContain('Only devices on an old plugin version without delta support') expect(container.textContent).not.toContain('Confirm') findDialogButton(container, 'Close').click() await nextTick() expect(dialogStore.showDialog).toBe(false) }) it('formats cache TTL as a human duration', () => { const { t } = mountDialogShell() expect(formatRolloutCacheTtlHuman(2592000, t)).toBe('30 days') expect(formatRolloutCacheTtlHuman(3600, t)).toBe('1 hour') expect(formatRolloutCacheTtlHuman(90, t)).toBe('1 minute 30 seconds') expect(formatRolloutCacheTtlDisplay(2592000, t)).toBe('30 days (2592000s)') }) it('update package descriptions stay documented for every option', () => { const { t } = mountDialogShell() for (const option of ['all', 'zip', 'delta', 'zip_from_builtin', 'delta_from_builtin'] as const) { expect(getUpdatePackageDescription(t, option).length).toBeGreaterThan(10) expect(getUpdatePackageInfoDescription(t, option)).toContain(getUpdatePackageDescription(t, option)) } expect(getUpdatePackageInfoDescription(t)).toContain(getUpdatePackageDescription(t, 'all')) }) })