fix(web): allow closing pending plugin installations
This commit is contained in:
1 parent
f83800530c
commit
736730c3fd
12 files changed
+342
-70
No files matched your search
@@ -0,0 +1,8 @@
|
||||
- dialog "正在准备安装…":
|
||||
- button "返回编辑": 编辑
|
||||
- button "取消安装并关闭"
|
||||
- status: 正在准备安装…
|
||||
- paragraph: slow-package
|
||||
- paragraph: 版本 1.0.0
|
||||
- button "查看安装详情"
|
||||
- button "取消安装"
|
||||
@@ -0,0 +1,9 @@
|
||||
- dialog "安装状态尚未确认":
|
||||
- button "返回编辑": 编辑
|
||||
- button "取消安装并关闭"
|
||||
- status: 安装状态尚未确认
|
||||
- alert: 尚未确认安装已停止,请重试取消或等待安装结果。
|
||||
- paragraph: slow-package
|
||||
- paragraph: 版本 1.0.0
|
||||
- button "查看安装详情"
|
||||
- button "取消安装"
|
||||
@@ -17,6 +17,8 @@ it('cancels installation through the UI, restores files, and offers the spec aga
|
||||
try {
|
||||
scaffold = await launchWebScaffold({ profile: { packages: [] }, extraOverlayPath: overlay })
|
||||
const browser = await chromium.launch()
|
||||
const delivery = Promise.withResolvers<undefined>()
|
||||
const cancellationReply = Promise.withResolvers<undefined>()
|
||||
try {
|
||||
const profile = join(scaffold.harnessHome, 'profiles', 'scaffold')
|
||||
const manifestPath = join(profile, 'package.json')
|
||||
@@ -45,6 +47,45 @@ it('cancels installation through the UI, restores files, and offers the spec aga
|
||||
// The dialog is named after its current screen, so it is found by role alone.
|
||||
const dialog = page.getByRole('dialog')
|
||||
await dialog.getByRole('textbox').fill('slow-package')
|
||||
// Hold request delivery and the real Host's cancellation reply independently.
|
||||
// Cancellation reaches the Host before the install it names.
|
||||
const cancellationArrived = Promise.withResolvers<undefined>()
|
||||
await page.route('**/api/pluginManager/installBundle', async (route) => {
|
||||
await delivery.promise
|
||||
await route.continue()
|
||||
})
|
||||
await page.route('**/api/pluginManager/cancelInstall', async (route) => {
|
||||
const response = await route.fetch()
|
||||
cancellationArrived.resolve(undefined)
|
||||
await cancellationReply.promise
|
||||
await route.fulfill({ response })
|
||||
})
|
||||
await dialog.getByRole('button', { name: '安装', exact: true }).click()
|
||||
await dialog.getByText('正在准备安装…', { exact: true }).waitFor()
|
||||
await compareOrRefreshGolden(fileURLToPath(new URL('./expected/plugin-install-cancel/preparing.expected.md', import.meta.url)),
|
||||
await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd), webSnapshotMode())
|
||||
await dialog.getByRole('button', { name: '取消安装并关闭', exact: true }).click()
|
||||
await dialog.waitFor({ state: 'hidden' })
|
||||
await cancellationArrived.promise
|
||||
await panel.getByRole('button', { name: '查看安装任务', exact: true }).click()
|
||||
await dialog.getByText('正在停止安装…', { exact: true }).first().waitFor()
|
||||
await page.keyboard.press('Escape')
|
||||
await dialog.waitFor({ state: 'hidden' })
|
||||
cancellationReply.resolve(undefined)
|
||||
await page.getByText('尚未确认安装已停止,可查看安装任务并重试取消。', { exact: true }).waitFor()
|
||||
await panel.getByRole('button', { name: '查看安装任务', exact: true }).click()
|
||||
await dialog.getByText('安装状态尚未确认', { exact: true }).waitFor()
|
||||
await compareOrRefreshGolden(fileURLToPath(new URL('./expected/plugin-install-cancel/unconfirmed.expected.md', import.meta.url)),
|
||||
await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd), webSnapshotMode())
|
||||
await page.locator('[role="presentation"] > [aria-hidden="true"]').click({ position: { x: 5, y: 5 } })
|
||||
await dialog.waitFor({ state: 'hidden' })
|
||||
delivery.resolve(undefined)
|
||||
await page.getByText('已取消安装,插件未启用,下载的文件可能保留', { exact: true }).waitFor()
|
||||
expect(await readFile(manifestPath, 'utf8')).toBe(manifest)
|
||||
expect(await readFile(lockPath, 'utf8')).toBe('original lockfile\n')
|
||||
await page.unrouteAll({ behavior: 'wait' })
|
||||
await panel.getByRole('button', { name: '添加插件', exact: true }).click()
|
||||
await dialog.getByRole('textbox').fill('slow-package')
|
||||
await dialog.getByRole('button', { name: '安装', exact: true }).click()
|
||||
// The check passed: the running screen names the package and folds pnpm's output behind the details.
|
||||
await dialog.getByText('版本 1.0.0', { exact: true }).waitFor()
|
||||
@@ -52,6 +93,7 @@ it('cancels installation through the UI, restores files, and offers the spec aga
|
||||
await dialog.getByText('Waiting for package download', { exact: true }).waitFor()
|
||||
await dialog.getByRole('button', { name: '取消安装', exact: true }).click()
|
||||
// The Host's confirmation returns the dialog to the spec and says so in a toast.
|
||||
await dialog.getByRole('textbox').waitFor()
|
||||
await page.getByText('已取消安装,插件未启用,下载的文件可能保留', { exact: true }).waitFor()
|
||||
expect(await readFile(manifestPath, 'utf8')).toBe(manifest)
|
||||
expect(await readFile(lockPath, 'utf8')).toBe('original lockfile\n')
|
||||
@@ -76,7 +118,11 @@ it('cancels installation through the UI, restores files, and offers the spec aga
|
||||
await dialog.getByText('Retry completed', { exact: true }).waitFor()
|
||||
expect(JSON.parse(await readFile(manifestPath, 'utf8'))).toMatchObject({ dependencies: { 'slow-package': '1.0.0' } })
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
} finally { await browser.close() }
|
||||
} finally {
|
||||
delivery.resolve(undefined)
|
||||
cancellationReply.resolve(undefined)
|
||||
await browser.close()
|
||||
}
|
||||
} finally {
|
||||
await scaffold?.close()
|
||||
await rm(scratch, { recursive: true, force: true })
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-plugin-manager/README.md
|
||||
README.md: 80de28b3000dfacf07ecfa805b7396d149249aba
|
||||
README.zh.md: 3368205f2ef9083e558ac34626f298c14304e995
|
||||
README.md: 21a40d900839b635f09f2150142d4fb3d1ee2d63
|
||||
README.zh.md: 00cca5c393c491b08be0ec2f2fbf8b9c30c5bb80
|
||||
@@ -33,7 +33,7 @@ The Agent Teams and Auto Authorization Review packages have localized names and
|
||||
|
||||
**Add plugin** takes a package name with an optional version, a Git address, a tarball, or an absolute local path; the dialog says a package name is what follows `dsh plugin add` in a README. **Not sure what to enter?** under the field opens a guide that shows the three common forms with an example each; **Use example** drops one into the field. **Install** first asks the Host to read what the spec names (`pluginManager.inspect`): a name the list already shows, a name the registry does not have, a path without a package, a package without a bundle patch, or a spec pnpm would refuse comes back under the field as one sentence, with the spec kept for editing. An accepted spec opens the installing screen, which shows the package's name, one-liner, and version as the Host read them and folds pnpm's command and output behind **Show install details**. A finished install offers **Enable now**, which switches the new bundle on, closes the dialog, and scrolls the list to it; closing instead leaves it installed and off. A failed install says what went wrong in one line — the registry or network could not be reached, the package was not found, the disk is full, the profile is not writable, pnpm blocked a build script — with pnpm's output behind the details and **Retry** at hand; the Host has already put the profile files back. When pnpm blocked a dependency's install scripts, the failed screen lists the packages whose scripts wait for permission and offers **Allow these scripts and retry** in place of **Retry**; the Host saves the permission in the profile's `pnpm-workspace.yaml`, which a failed run leaves as pnpm wrote it, then runs pnpm again, and the installed screen names what was allowed. A successful installation does not certify that a module can activate.
|
||||
|
||||
During installation, **Cancel install** asks the Host to stop the run and shows **Stopping installation…** until the Host confirms. Loading the bundle cannot be cancelled. Once confirmed, the dialog returns to the spec, ready to install again, and a toast says the installation was cancelled; the manifest and lockfile are back as they were, while downloaded files can remain. Closing the dialog while the run is in progress asks the Host to stop it the same way, and the dialog closes once the Host confirms; while the Host prepares, stops, or loads, the dialog cannot be closed. A connection error does not confirm cancellation: the running screen says so and cancelling can be tried again.
|
||||
During preparation and download, **Cancel install** asks the Host to stop the run and waits for confirmation. Loading the bundle cannot be cancelled. Closing with ×, Escape, or the backdrop hides the dialog immediately and requests cancellation when possible. **View installation** reopens the same task with its output; another installation cannot start while its outcome is pending or unconfirmed. Confirmed cancellation returns to the spec and shows a toast; the manifest and lockfile are restored, while downloaded files can remain. A connection error shows **Installation status unconfirmed** and allows retrying cancellation. Hidden tasks report completion, failure, or unconfirmed cancellation through toasts without reopening the dialog.
|
||||
|
||||
### Switching a bundle
|
||||
|
||||
@@ -73,7 +73,7 @@ The browser plugin registers the `plugins` sidebar entry and its `main` panel th
|
||||
|
||||
### The store
|
||||
|
||||
`PluginManagerController` owns the bundle views, busy keys, notices, install progress and the uninstall confirmation. Each read asks the inventory whether the Host manages a profile, then joins `listBundles` with `listPlugins` into one view per bundle, whose rows carry the live entry's enablement and fiber phase. It coalesces overlapping reads, refreshes after operations, on `plugin-manager/changed`, and on reconnect, and ignores late results after disposal. Install output is grouped by job id. The install dialog moves `idle → checking → starting → running → done | failed`, with `cancelling` and `applying` as the Host reports them; checking and every active phase use ongoing, while the final screens use done or error. The check runs under an `AbortController` that going back or closing aborts, and its settlement is dropped; a run is stopped only through `pluginManager.cancelInstall`, whose answer the dialog waits for. A change the Host could not apply, a restart it waits for, and an override by a higher layer become toasts that retire on their own.
|
||||
`PluginManagerController` owns the bundle views, busy keys, notices, install progress and the uninstall confirmation. Each read asks the inventory whether the Host manages a profile, then joins `listBundles` with `listPlugins` into one view per bundle, whose rows carry the live entry's enablement and fiber phase. It coalesces overlapping reads, refreshes after operations, on `plugin-manager/changed`, and on reconnect, and ignores late results after disposal. Install output is grouped by job id. The install dialog moves `idle → checking → starting → running → done | failed`, with `cancelling` and `applying` as the Host reports them and `unconfirmed` when an installation or cancellation response is lost; checking and every active phase use ongoing, while the final screens use done or error. The check runs under an `AbortController` that going back or closing aborts, and its settlement is dropped; a run is stopped only through `pluginManager.cancelInstall`. Closing hides a run without discarding its state. A cancellation that overtakes installation is retried when progress or output acknowledges the request. A change the Host could not apply, a restart it waits for, and an override by a higher layer become toasts that retire on their own.
|
||||
|
||||
### Configuration slots
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ Agent Teams 和 Auto Authorization Review 两个包使用随界面语言切换
|
||||
|
||||
**添加插件**接受包名(可带版本)、Git 地址、压缩包或本地绝对路径;对话框说明包名就是 README 里 `dsh plugin add` 后面的那一段。输入框下方的**不知道该填什么?**展开一段引导,给出三种常见形式各一个示例;**填入示例**把示例填进输入框。**安装**先让 Host 读出 spec 指向什么(`pluginManager.inspect`):列表中已有的名字、注册表没有的名字、没有包的路径、没有组合包 patch 的包,或 pnpm 会拒绝的 spec,都以一句话回到输入框下方,spec 保留可继续编辑。通过检查的 spec 打开安装中界面,展示 Host 读到的包名、一句话简介和版本,pnpm 的命令与输出折叠在**查看安装详情**之后。安装完成后提供**立即启用**:启用新组合包、关闭对话框并把列表滚动到它;直接关闭则让它保持已安装但关闭。安装失败时用一行话说明原因——注册表或网络不可达、包不存在、磁盘已满、profile 不可写、pnpm 拦下了构建脚本——pnpm 输出在详情里,**重试**就在手边;Host 已经把 profile 文件放回原样。pnpm 拦下依赖的安装脚本时,失败界面列出等待允许的包,并以**允许这些脚本并重试**取代**重试**;Host 把授权写进 profile 的 `pnpm-workspace.yaml`(失败的运行保留 pnpm 写入的这个文件)再运行 pnpm,安装完成界面会说明允许了哪些脚本。安装成功不代表模块一定能够激活。
|
||||
|
||||
安装期间可点击**取消安装**,对话框显示**正在停止安装…**,直到 Host 确认。加载组合包的阶段不可取消。确认后对话框回到 spec 输入界面,可再次安装,并用 toast 说明安装已取消;manifest 与 lockfile 已恢复原样,已下载文件可能保留。安装进行中关闭对话框,同样会请求 Host 停止安装,Host 确认后对话框才关闭;Host 正在准备、停止或加载时不能关闭对话框。连接错误不代表取消成功:安装中界面会如此说明,可以再次尝试取消。
|
||||
准备和下载期间,**取消安装**会请求 Host 停止运行并等待确认。加载组合包的阶段不可取消。点击 ×、按 Escape 或点击遮罩会立即隐藏对话框,并在可以取消时请求取消。**查看安装任务**会重新打开同一任务并保留输出;结果待定或尚未确认时不能发起另一项安装。确认取消后回到 spec 输入界面并显示 toast;manifest 与 lockfile 已恢复,已下载文件可能保留。连接错误会显示**安装状态尚未确认**,允许重试取消。隐藏的任务通过 toast 通知完成、失败或取消尚未确认,不会重新弹出对话框。
|
||||
|
||||
### 切换一个组合包
|
||||
|
||||
@@ -73,7 +73,7 @@ ctx.slots.inject('plugins.row.config', () => ctx.slots.register({
|
||||
|
||||
### store
|
||||
|
||||
`PluginManagerController` 拥有组合包视图、忙碌键、提示、安装进度和卸载确认。每次读取先问清单 Host 是否管理着 profile,再把 `listBundles` 与 `listPlugins` 合成每个组合包一份视图,其行携带存活条目的启停状态与 fiber 阶段。它合并重叠读取,在操作后、收到 `plugin-manager/changed` 时以及重连后刷新,并在销毁后忽略晚到结果。安装输出按 job id 分组。安装对话框沿 `idle → checking → starting → running → done | failed` 推进,`cancelling` 与 `applying` 按 Host 的报告呈现;checking 与所有活动阶段使用 ongoing,最终页面使用 done 或 error。检查在一个 `AbortController` 下运行,返回编辑或关闭会中止它并丢弃其结果;运行只能通过 `pluginManager.cancelInstall` 停止,对话框等待其答复。Host 无法应用的变更、要等重启的变更、被更高层覆盖的变更,都是会自行消失的 toast。
|
||||
`PluginManagerController` 拥有组合包视图、忙碌键、提示、安装进度和卸载确认。每次读取先问清单 Host 是否管理着 profile,再把 `listBundles` 与 `listPlugins` 合成每个组合包一份视图,其行携带存活条目的启停状态与 fiber 阶段。它合并重叠读取,在操作后、收到 `plugin-manager/changed` 时以及重连后刷新,并在销毁后忽略晚到结果。安装输出按 job id 分组。安装对话框沿 `idle → checking → starting → running → done | failed` 推进,`cancelling` 与 `applying` 按 Host 的报告呈现,安装或取消响应丢失时进入 `unconfirmed`;checking 与所有活动阶段使用 ongoing,最终页面使用 done 或 error。检查在一个 `AbortController` 下运行,返回编辑或关闭会中止它并丢弃其结果;运行只能通过 `pluginManager.cancelInstall` 停止。关闭会隐藏任务而保留其状态。如果取消请求先于安装到达,在进度或输出确认该请求后会再次请求取消。Host 无法应用的变更、要等重启的变更、被更高层覆盖的变更,都是会自行消失的 toast。
|
||||
|
||||
### 配置 slot
|
||||
|
||||
|
||||
@@ -552,6 +552,7 @@ const SCREEN_TITLE_KEYS = {
|
||||
running: 'installingTitle',
|
||||
cancelling: 'installCancelling',
|
||||
applying: 'installApplying',
|
||||
unconfirmed: 'installUnconfirmedTitle',
|
||||
done: 'installedTitle',
|
||||
failed: 'installFailedTitle',
|
||||
} satisfies Record<Exclude<InstallState['phase'], 'idle' | 'checking'>, PluginManagerLocaleKey>
|
||||
@@ -597,7 +598,7 @@ function SubjectCard({ subject, t }: { readonly subject: InstallSubject; readonl
|
||||
* install scripts undecided shows them for approval in place of plain retry.
|
||||
*/
|
||||
function InstallDialog({
|
||||
install, t, onClose, onEditSpec, onRun, onCancel, onCancelAndClose, onToggleDetails, onEnableNow, onApproveBuilds,
|
||||
install, t, onClose, onEditSpec, onRun, onCancel, onToggleDetails, onEnableNow, onApproveBuilds,
|
||||
}: {
|
||||
readonly install: InstallState
|
||||
readonly t: Translate
|
||||
@@ -605,8 +606,6 @@ function InstallDialog({
|
||||
readonly onEditSpec: (text: string) => void
|
||||
readonly onRun: () => void
|
||||
readonly onCancel: () => void
|
||||
/** The close control while the Host runs the install: stop the run, then close. */
|
||||
readonly onCancelAndClose: () => void
|
||||
readonly onToggleDetails: () => void
|
||||
readonly onEnableNow: () => void
|
||||
readonly onApproveBuilds: () => void
|
||||
@@ -703,8 +702,8 @@ function InstallDialog({
|
||||
}
|
||||
const heading = t(SCREEN_TITLE_KEYS[phase])
|
||||
const pending = isInstallPending(phase)
|
||||
// Only a run the Host acknowledged can be stopped; before that, and while it stops or applies, the controls wait.
|
||||
const stoppable = phase === 'running' || phase === 'failed'
|
||||
const cancellable = phase === 'starting' || phase === 'running' || phase === 'unconfirmed'
|
||||
const stoppable = cancellable || phase === 'failed'
|
||||
const unconfirmed = install.failure?.cancelUnconfirmed === true ? install.failure.reason : undefined
|
||||
const pendingBuilds = phase === 'failed' ? install.failure?.pendingBuilds ?? [] : []
|
||||
const approvable = pendingBuilds.length > 0
|
||||
@@ -724,9 +723,8 @@ function InstallDialog({
|
||||
<button
|
||||
type="button"
|
||||
className={css.wizardClose}
|
||||
aria-label={t(phase === 'running' ? 'installCloseCancels' : 'close')}
|
||||
disabled={pending && phase !== 'running'}
|
||||
onClick={phase === 'running' ? onCancelAndClose : onClose}
|
||||
aria-label={t(cancellable ? 'installCloseCancels' : 'close')}
|
||||
onClick={onClose}
|
||||
>
|
||||
<IconCloseOutlineRegular size={14} />
|
||||
</button>
|
||||
@@ -771,7 +769,7 @@ function InstallDialog({
|
||||
</button>
|
||||
{pending
|
||||
? (
|
||||
<Button variant="outline" size="sm" disabled={phase !== 'running'} onClick={onCancel}>
|
||||
<Button variant="outline" size="sm" disabled={!cancellable} onClick={onCancel}>
|
||||
{t(phase === 'cancelling' ? 'installCancelling' : 'installCancel')}
|
||||
</Button>
|
||||
)
|
||||
@@ -918,7 +916,9 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode {
|
||||
<button type="button" className={css.iconButton} aria-label={t('refresh')} title={t('refresh')} disabled={!loaded} onClick={props.refresh}>
|
||||
<span className={css.iconWrap} aria-hidden="true"><IconRefreshOutlineRegular /></span>
|
||||
</button>
|
||||
<Button variant="primary" size="sm" icon={<IconPlusOutlineRegular size={13} />} disabled={!loaded} onClick={props.openInstall}>{t('addPlugin')}</Button>
|
||||
<Button variant="primary" size="sm" icon={<IconPlusOutlineRegular size={13} />} disabled={!loaded} onClick={props.openInstall}>
|
||||
{t(state.install.requestId === undefined ? 'addPlugin' : 'installViewTask')}
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
@@ -1002,7 +1002,6 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode {
|
||||
onEditSpec={props.editInstallSpec}
|
||||
onRun={props.runInstall}
|
||||
onCancel={props.cancelInstall}
|
||||
onCancelAndClose={props.cancelInstallAndClose}
|
||||
onToggleDetails={props.toggleInstallDetails}
|
||||
onEnableNow={props.enableInstalled}
|
||||
onApproveBuilds={props.approveBuildsAndRetry}
|
||||
|
||||
@@ -86,6 +86,12 @@ export const zh = {
|
||||
installEditAria: '返回编辑',
|
||||
installCancel: '取消安装',
|
||||
installCloseCancels: '取消安装并关闭',
|
||||
installViewTask: '查看安装任务',
|
||||
installUnconfirmedTitle: '安装状态尚未确认',
|
||||
installBackgroundDone: '插件安装已完成,可查看安装结果。',
|
||||
installBackgroundFailed: '插件安装失败,可查看安装详情。',
|
||||
installBackgroundUnconfirmed: '尚未确认安装已停止,可查看安装任务并重试取消。',
|
||||
installBackgroundApplying: '安装已进入收尾阶段,无法取消,可查看安装进度。',
|
||||
installStarting: '正在准备安装…',
|
||||
installCancelling: '正在停止安装…',
|
||||
installApplying: '正在应用配置,请稍候…',
|
||||
@@ -246,6 +252,12 @@ export const en = {
|
||||
installEditAria: 'Back to editing',
|
||||
installCancel: 'Cancel install',
|
||||
installCloseCancels: 'Cancel install and close',
|
||||
installViewTask: 'View installation',
|
||||
installUnconfirmedTitle: 'Installation status unconfirmed',
|
||||
installBackgroundDone: 'Installation finished. View installation details.',
|
||||
installBackgroundFailed: 'Installation failed. View installation details.',
|
||||
installBackgroundUnconfirmed: 'Installation has not been confirmed stopped. View the installation to retry cancellation.',
|
||||
installBackgroundApplying: 'Installation is being applied and cannot be cancelled. View installation progress.',
|
||||
installStarting: 'Preparing installation…',
|
||||
installCancelling: 'Stopping installation…',
|
||||
installApplying: 'Applying configuration, please wait…',
|
||||
|
||||
@@ -35,6 +35,7 @@ export type ManagerNotice =
|
||||
| { readonly kind: 'restart'; readonly packageName: string; readonly seq: number }
|
||||
| { readonly kind: 'overridden'; readonly packageName: string; readonly seq: number }
|
||||
| { readonly kind: 'cancelled'; readonly seq: number }
|
||||
| { readonly kind: 'install'; readonly outcome: 'done' | 'failed' | 'unconfirmed' | 'applying'; readonly seq: number }
|
||||
| {
|
||||
readonly kind: 'failed'
|
||||
/** What was being done when it failed. */
|
||||
@@ -108,14 +109,16 @@ export interface InstallRun {
|
||||
* `checking`, then installed through the Host-owned phases — `starting` until
|
||||
* the Host acknowledges the run, `running`, `cancelling` while the Host stops
|
||||
* it, `applying` once the bundle is being loaded, which closed the cancellation
|
||||
* window — and the outcome shown as `done` or `failed`. A refused check, a
|
||||
* window — and the outcome shown as `done` or `failed`. A lost response leaves
|
||||
* `unconfirmed`, with cancellation and closing available. Closing preserves the
|
||||
* Host-owned operation. A refused check, a
|
||||
* confirmed cancellation, and the back control return to `idle` with the spec kept.
|
||||
*/
|
||||
export interface InstallState {
|
||||
readonly open: boolean
|
||||
/** The package spec as typed. */
|
||||
readonly spec: string
|
||||
readonly phase: 'idle' | 'checking' | 'starting' | 'running' | 'cancelling' | 'applying' | 'done' | 'failed'
|
||||
readonly phase: 'idle' | 'checking' | 'starting' | 'running' | 'cancelling' | 'applying' | 'unconfirmed' | 'done' | 'failed'
|
||||
/** Identifies this dialog's installation, including log and cancellation messages. */
|
||||
readonly requestId?: PluginInstallRequestId
|
||||
/** Why the spec was refused before installing; shown under the field. */
|
||||
@@ -124,7 +127,7 @@ export interface InstallState {
|
||||
readonly subject: InstallSubject | null
|
||||
/** The pnpm runs of the open install, in the order they started. */
|
||||
readonly runs: readonly InstallRun[]
|
||||
/** Whether the run's command and output are unfolded. */
|
||||
/** Whether the run's command and output are unfolded, including after reopening. */
|
||||
readonly detailsOpen: boolean
|
||||
/** The bundle the finished run added, left off until enabled from the installed screen. */
|
||||
readonly installed: string | null
|
||||
@@ -157,7 +160,7 @@ export interface InstallState {
|
||||
* @returns true until an authoritative result settles the installation.
|
||||
*/
|
||||
export function isInstallPending(phase: InstallState['phase']): boolean {
|
||||
return phase === 'starting' || phase === 'running' || phase === 'cancelling' || phase === 'applying'
|
||||
return phase === 'starting' || phase === 'running' || phase === 'cancelling' || phase === 'applying' || phase === 'unconfirmed'
|
||||
}
|
||||
|
||||
/** A destructive action waiting for the user's confirmation: a package's uninstall. */
|
||||
@@ -193,7 +196,7 @@ export interface PluginManagerFace {
|
||||
/** Read the Host again. */
|
||||
refresh: () => void
|
||||
openInstall: () => void
|
||||
/** Close the dialog; a check in flight is dropped, a Host-owned run has to be cancelled first. */
|
||||
/** Hide immediately, abort a check, or request cancellation while retaining the Host-owned installation. */
|
||||
closeInstall: () => void
|
||||
editInstallSpec: (text: string) => void
|
||||
/** Check the spec with the Host, then install it; from the failed screen, run it again. */
|
||||
@@ -202,11 +205,6 @@ export interface PluginManagerFace {
|
||||
approveBuildsAndRetry: () => void
|
||||
/** Leave the check or the failed screen for the spec, or ask the Host to stop the run and wait for its cleanup. */
|
||||
cancelInstall: () => void
|
||||
/**
|
||||
* While the Host runs the install, ask it to stop and close the dialog once
|
||||
* it confirms; the dialog stays when it cannot. Otherwise nothing.
|
||||
*/
|
||||
cancelInstallAndClose: () => void
|
||||
toggleInstallDetails: () => void
|
||||
/** Enable the bundle the finished install added, then close the dialog and mark it in the list. */
|
||||
enableInstalled: () => void
|
||||
@@ -314,6 +312,13 @@ const IDLE_INSTALL: InstallState = {
|
||||
installed: null, restartRequired: false, failure: null, approvedBuilds: [], enabling: false,
|
||||
}
|
||||
|
||||
/** Cancellation stays owned until the Host accepts the install or confirms its outcome. */
|
||||
interface InstallCancellation {
|
||||
readonly requestId: PluginInstallRequestId
|
||||
acknowledged: boolean
|
||||
waitingForStart: boolean
|
||||
}
|
||||
|
||||
/** Reads and mutates the profile's plugins through the `pluginManager` Remote. */
|
||||
export class PluginManagerController {
|
||||
private readonly store: SnapshotStore<PluginManagerState>
|
||||
@@ -324,6 +329,7 @@ export class PluginManagerController {
|
||||
private pendingConfirm: (() => Promise<void>) | undefined
|
||||
/** Cancels the check the dialog has in flight. */
|
||||
private inspectAbort: AbortController | undefined
|
||||
private cancellation: InstallCancellation | undefined
|
||||
private noticeSeq = 0
|
||||
|
||||
/**
|
||||
@@ -363,10 +369,15 @@ export class PluginManagerController {
|
||||
ensure: () => { if (this.getSnapshot().status === 'idle') void this.load() },
|
||||
refresh: () => { void this.load() },
|
||||
openInstall: () => {
|
||||
if (!isInstallPending(this.getSnapshot().install.phase)) this.patch({ install: { ...IDLE_INSTALL, open: true } })
|
||||
const install = this.getSnapshot().install
|
||||
this.patch({ install: { ...(install.requestId === undefined ? IDLE_INSTALL : install), open: true } })
|
||||
},
|
||||
closeInstall: () => {
|
||||
if (isInstallPending(this.getSnapshot().install.phase)) return
|
||||
if (isInstallPending(this.getSnapshot().install.phase)) {
|
||||
this.patchInstall({ open: false })
|
||||
void this.cancelInstall()
|
||||
return
|
||||
}
|
||||
this.abortInspect()
|
||||
this.patch({ install: IDLE_INSTALL })
|
||||
},
|
||||
@@ -379,7 +390,6 @@ export class PluginManagerController {
|
||||
runInstall: () => { void this.runInstall() },
|
||||
approveBuildsAndRetry: () => { void this.approveBuildsAndRetry() },
|
||||
cancelInstall: () => { void this.cancelInstall() },
|
||||
cancelInstallAndClose: () => { void this.cancelInstall(true) },
|
||||
toggleInstallDetails: () => { this.patchInstall({ detailsOpen: !this.getSnapshot().install.detailsOpen }) },
|
||||
enableInstalled: () => { void this.enableInstalled() },
|
||||
clearHighlight: () => { if (this.getSnapshot().highlight !== null) this.patch({ highlight: null }) },
|
||||
@@ -412,6 +422,14 @@ export class PluginManagerController {
|
||||
installProgress(progress: PluginInstallProgress): void {
|
||||
const install = this.getSnapshot().install
|
||||
if (install.requestId !== progress.requestId || !isInstallPending(install.phase)) return
|
||||
const cancellation = this.cancellation
|
||||
if (progress.phase === 'installing' && cancellation?.requestId === progress.requestId) {
|
||||
cancellation.acknowledged = true
|
||||
if (cancellation.waitingForStart) {
|
||||
void this.sendCancellation(cancellation)
|
||||
return
|
||||
}
|
||||
}
|
||||
// A queued start notification cannot undo the local user's cancellation request.
|
||||
if (install.phase === 'cancelling' && progress.phase === 'installing') return
|
||||
this.patchInstall({ phase: progress.phase === 'installing' ? 'running' : progress.phase })
|
||||
@@ -425,6 +443,10 @@ export class PluginManagerController {
|
||||
appendLog(chunk: PluginInstallLogChunk): void {
|
||||
const install = this.getSnapshot().install
|
||||
if (chunk.requestId !== install.requestId) return
|
||||
// Output also acknowledges an install when its start notification was lost.
|
||||
if (install.requestId !== undefined && (install.phase === 'starting' || install.phase === 'cancelling' || install.phase === 'unconfirmed')) {
|
||||
this.installProgress({ requestId: install.requestId, phase: 'installing' })
|
||||
}
|
||||
const index = install.runs.findIndex(run => run.jobId === chunk.jobId)
|
||||
if (index === -1 && !isInstallPending(install.phase)) return
|
||||
const settled = chunk.exitCode === undefined ? {} : { exitCode: chunk.exitCode }
|
||||
@@ -553,6 +575,7 @@ export class PluginManagerController {
|
||||
private async startInstall(subject: InstallSubject, approvedBuilds?: readonly string[]): Promise<void> {
|
||||
const { spec } = subject
|
||||
const requestId = randomUUID() as PluginInstallRequestId
|
||||
this.cancellation = undefined
|
||||
this.patchInstall({ phase: 'starting', requestId, subject, runs: [], failure: null, installed: null, approvedBuilds: [] })
|
||||
// The Host announces `plugin-manager/changed` while the run is still on
|
||||
// the wire, and every such event reads again; those reads must not cancel
|
||||
@@ -563,7 +586,10 @@ export class PluginManagerController {
|
||||
if (this.disposed || this.getSnapshot().install.requestId !== requestId) return
|
||||
const runs = this.getSnapshot().install.runs
|
||||
if (!result.ok) {
|
||||
this.patchInstall({ phase: 'failed', runs: settledRuns(runs, null), failure: { reason: result.error.message } })
|
||||
// A failed transport does not establish whether the Host stopped the installation.
|
||||
this.patchInstall({ phase: 'unconfirmed', failure: { reason: result.error.message, cancelUnconfirmed: true } })
|
||||
this.notifyHiddenInstall('unconfirmed')
|
||||
return
|
||||
} else if (result.value.application === 'cancelled') {
|
||||
this.offerSpecAgain({ kind: 'cancelled', seq: ++this.noticeSeq })
|
||||
} else if (result.value.application === 'failed') {
|
||||
@@ -583,6 +609,9 @@ export class PluginManagerController {
|
||||
approvedBuilds: result.value.approvedBuilds ?? [],
|
||||
})
|
||||
}
|
||||
this.cancellation = undefined
|
||||
const phase = this.getSnapshot().install.phase
|
||||
if (phase === 'done' || phase === 'failed') this.notifyHiddenInstall(phase)
|
||||
void this.load()
|
||||
}
|
||||
|
||||
@@ -599,45 +628,74 @@ export class PluginManagerController {
|
||||
|
||||
/**
|
||||
* Leave the check or the failed screen for the spec at once; a Host-owned
|
||||
* run is asked to stop and the dialog waits for the Host's word, since
|
||||
* run is asked to stop and its state waits for the Host's word, since
|
||||
* neither a dropped RPC nor a closed connection means pnpm has stopped.
|
||||
* @param closeAfter - stop only a running install, and close the dialog once the Host confirms the stop.
|
||||
*/
|
||||
private async cancelInstall(closeAfter = false): Promise<void> {
|
||||
private async cancelInstall(): Promise<void> {
|
||||
const install = this.getSnapshot().install
|
||||
if (!closeAfter && (install.phase === 'checking' || install.phase === 'failed')) {
|
||||
if (install.phase === 'checking' || install.phase === 'failed') {
|
||||
this.abortInspect()
|
||||
this.offerSpecAgain()
|
||||
return
|
||||
}
|
||||
if (install.phase !== 'running' || install.requestId === undefined) return
|
||||
const requestId = install.requestId
|
||||
if ((install.phase !== 'starting' && install.phase !== 'running' && install.phase !== 'unconfirmed') || install.requestId === undefined) return
|
||||
if (this.cancellation !== undefined && !this.cancellation.waitingForStart) return
|
||||
const cancellation: InstallCancellation = {
|
||||
requestId: install.requestId, acknowledged: install.phase === 'running', waitingForStart: false,
|
||||
}
|
||||
this.cancellation = cancellation
|
||||
await this.sendCancellation(cancellation)
|
||||
}
|
||||
|
||||
/** A cancellation that overtakes installation is retried after the Host acknowledges that request. */
|
||||
private async sendCancellation(cancellation: InstallCancellation): Promise<void> {
|
||||
const { requestId, acknowledged } = cancellation
|
||||
cancellation.waitingForStart = false
|
||||
this.patchInstall({ phase: 'cancelling', failure: null })
|
||||
const result = await this.ctx.remote.pluginManager.cancelInstall(requestId)
|
||||
const current = this.getSnapshot().install
|
||||
if (this.disposed || current.requestId !== requestId || !isInstallPending(current.phase)) return
|
||||
if (this.disposed || this.cancellation !== cancellation || current.requestId !== requestId || !isInstallPending(current.phase)) return
|
||||
if (!result.ok) {
|
||||
this.patchInstall({ phase: 'running', failure: { reason: result.error.message, cancelUnconfirmed: true } })
|
||||
cancellation.waitingForStart = !cancellation.acknowledged
|
||||
if (cancellation.acknowledged) this.cancellation = undefined
|
||||
this.patchInstall({ phase: 'unconfirmed', failure: { reason: result.error.message, cancelUnconfirmed: true } })
|
||||
this.notifyHiddenInstall('unconfirmed')
|
||||
return
|
||||
}
|
||||
if (result.value.status === 'cancelled') {
|
||||
const notice: ManagerNotice = { kind: 'cancelled', seq: ++this.noticeSeq }
|
||||
if (closeAfter) this.patch({ install: IDLE_INSTALL, notice })
|
||||
else this.offerSpecAgain(notice)
|
||||
this.offerSpecAgain(notice)
|
||||
void this.load()
|
||||
} else if (result.value.status === 'too-late') {
|
||||
this.cancellation = undefined
|
||||
this.patchInstall({ phase: 'applying' })
|
||||
this.notifyHiddenInstall('applying')
|
||||
} else if (!acknowledged) {
|
||||
if (cancellation.acknowledged) {
|
||||
await this.sendCancellation(cancellation)
|
||||
} else {
|
||||
cancellation.waitingForStart = true
|
||||
this.patchInstall({ phase: 'unconfirmed', failure: { reason: '', cancelUnconfirmed: true } })
|
||||
this.notifyHiddenInstall('unconfirmed')
|
||||
}
|
||||
} else {
|
||||
this.patchInstall({ phase: 'running', failure: { reason: '', cancelUnconfirmed: true } })
|
||||
this.cancellation = undefined
|
||||
this.patchInstall({ phase: 'unconfirmed', failure: { reason: '', cancelUnconfirmed: true } })
|
||||
this.notifyHiddenInstall('unconfirmed')
|
||||
}
|
||||
}
|
||||
|
||||
private notifyHiddenInstall(outcome: Extract<ManagerNotice, { kind: 'install' }>['outcome']): void {
|
||||
if (!this.getSnapshot().install.open) this.patch({ notice: { kind: 'install', outcome, seq: ++this.noticeSeq } })
|
||||
}
|
||||
|
||||
/**
|
||||
* Back to the spec: the check, the run, and its request are forgotten and
|
||||
* the spec is kept, with a toast when there is something to say — the Host
|
||||
* stopped the run.
|
||||
*/
|
||||
private offerSpecAgain(notice: ManagerNotice | null = null): void {
|
||||
this.cancellation = undefined
|
||||
const { open, spec } = this.getSnapshot().install
|
||||
this.patch({ install: { ...IDLE_INSTALL, open, spec }, ...notice === null ? {} : { notice } })
|
||||
}
|
||||
|
||||
@@ -90,6 +90,10 @@ export function noticeText(notice: ManagerNotice, t: Translate): string {
|
||||
case 'restart': return t('restartNotice')
|
||||
case 'overridden': return t('overriddenNotice', { name: notice.packageName })
|
||||
case 'cancelled': return t('installCancelled')
|
||||
case 'install': return t(({
|
||||
done: 'installBackgroundDone', failed: 'installBackgroundFailed',
|
||||
unconfirmed: 'installBackgroundUnconfirmed', applying: 'installBackgroundApplying',
|
||||
} as const)[notice.outcome])
|
||||
case 'failed': {
|
||||
const reason = notice.code === undefined ? notice.reason : managementText({ code: notice.code, diagnostic: notice.reason }, t)
|
||||
return t(FAILED_KEYS[notice.action], { reason: reason === '' ? t('reasonOperationError') : reason })
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { PluginEntryId } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import type { PluginEntryId, PluginInstallRequestId } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
|
||||
import type { ReactNode } from 'react'
|
||||
@@ -68,7 +68,6 @@ function renderTab(state: Partial<PluginManagerState> = {}, config: Partial<Conf
|
||||
editInstallSpec: vi.fn(),
|
||||
runInstall: vi.fn(),
|
||||
cancelInstall: vi.fn(),
|
||||
cancelInstallAndClose: vi.fn(),
|
||||
toggleInstallDetails: vi.fn(),
|
||||
approveBuildsAndRetry: vi.fn(),
|
||||
enableInstalled: vi.fn(),
|
||||
@@ -505,18 +504,17 @@ describe('PluginManagerPage', () => {
|
||||
expect(actions.cancelInstall).toHaveBeenCalledTimes(2)
|
||||
expect(screen.queryByRole('button', { name: en.close })).toBeNull()
|
||||
fireEvent.click(screen.getByRole('button', { name: en.installCloseCancels }))
|
||||
expect(actions.cancelInstallAndClose).toHaveBeenCalledOnce()
|
||||
expect(actions.closeInstall).not.toHaveBeenCalled()
|
||||
expect(actions.closeInstall).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('waits with the Host through starting, stopping, and applying, and words an unconfirmed stop', () => {
|
||||
const subject = { spec: 'slow', status: 'accepted', kind: 'registry', name: 'slow', bundle: true } as const
|
||||
const { actions, set } = renderTab({ install: { ...IDLE_INSTALL, open: true, spec: 'slow', phase: 'starting', subject } })
|
||||
// Before the Host acknowledges the run there is nothing to stop: cancel, back, and close all wait.
|
||||
expect(screen.getByRole('status').textContent).toBe(en.installStarting)
|
||||
expect(screen.getByRole('button', { name: en.installCancel })).toHaveProperty('disabled', true)
|
||||
expect(screen.getByRole('button', { name: en.installEditAria })).toHaveProperty('disabled', true)
|
||||
expect(screen.getByRole('button', { name: en.close })).toHaveProperty('disabled', true)
|
||||
expect(screen.getByRole('button', { name: en.installCancel })).toHaveProperty('disabled', false)
|
||||
expect(screen.getByRole('button', { name: en.installEditAria })).toHaveProperty('disabled', false)
|
||||
fireEvent.click(screen.getByRole('button', { name: en.installCloseCancels }))
|
||||
expect(actions.closeInstall).toHaveBeenCalledOnce()
|
||||
set({ install: { ...IDLE_INSTALL, open: true, spec: 'slow', phase: 'running', subject } })
|
||||
fireEvent.click(screen.getByRole('button', { name: en.installCancel }))
|
||||
fireEvent.click(screen.getByRole('button', { name: en.installEditAria }))
|
||||
@@ -530,10 +528,12 @@ describe('PluginManagerPage', () => {
|
||||
})
|
||||
expect(screen.getByRole('status').textContent).toBe(en.installCancelling)
|
||||
expect(screen.getByRole('button', { name: en.installCancelling })).toHaveProperty('disabled', true)
|
||||
expect(screen.getByRole('button', { name: en.close })).toHaveProperty('disabled', false)
|
||||
expect(within(document.querySelector('[data-terminal]') as HTMLElement).getByText(en.installCancelledShort)).toBeTruthy()
|
||||
set({ install: { ...IDLE_INSTALL, open: true, spec: 'slow', phase: 'applying', subject } })
|
||||
expect(screen.getByRole('status').textContent).toBe(en.installApplying)
|
||||
expect(screen.getByRole('button', { name: en.installCancel })).toHaveProperty('disabled', true)
|
||||
expect(screen.getByRole('button', { name: en.close })).toHaveProperty('disabled', false)
|
||||
// A stop the Host could not confirm says so over the running screen.
|
||||
set({ install: { ...IDLE_INSTALL, open: true, spec: 'slow', phase: 'running', subject, failure: { reason: 'offline', cancelUnconfirmed: true } } })
|
||||
expect(screen.getByRole('alert').textContent).toContain('offline')
|
||||
@@ -728,6 +728,11 @@ describe('PluginManagerPage', () => {
|
||||
expect(screen.getByRole('alert').textContent).toContain(en.failedDisable.replace('{reason}', en.reasonOperationError))
|
||||
set({ notice: { kind: 'failed', action: 'rowEnable', reason: 'x', packageName: 'pkg-1', seq: 8 } })
|
||||
expect(screen.getByRole('alert').textContent).toContain(en.failedRowEnable.replace('{reason}', 'x'))
|
||||
for (const [index, outcome] of (['done', 'failed', 'unconfirmed', 'applying'] as const).entries()) {
|
||||
set({ notice: { kind: 'install', outcome, seq: 9 + index } })
|
||||
const key = ({ done: 'installBackgroundDone', failed: 'installBackgroundFailed', unconfirmed: 'installBackgroundUnconfirmed', applying: 'installBackgroundApplying' } as const)[outcome]
|
||||
expect(screen.getByRole('alert').textContent).toContain(en[key])
|
||||
}
|
||||
// No button to press: the toast retires on its own and the store forgets it.
|
||||
expect(screen.queryByRole('button', { name: /got it/i })).toBeNull()
|
||||
expect(actions.dismissNotice).not.toHaveBeenCalled()
|
||||
@@ -738,4 +743,18 @@ describe('PluginManagerPage', () => {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('offers the retained installation and keeps its uncertain state cancellable', () => {
|
||||
const { actions } = renderTab({ install: {
|
||||
...IDLE_INSTALL, open: true, phase: 'unconfirmed', requestId: 'pending-install' as PluginInstallRequestId,
|
||||
failure: { reason: 'offline', cancelUnconfirmed: true },
|
||||
} })
|
||||
fireEvent.click(screen.getByRole('button', { name: en.installViewTask }))
|
||||
expect(actions.openInstall).toHaveBeenCalledOnce()
|
||||
expect(screen.getByRole('status').textContent).toBe(en.installUnconfirmedTitle)
|
||||
fireEvent.click(screen.getByRole('button', { name: en.installCancel }))
|
||||
expect(actions.cancelInstall).toHaveBeenCalledOnce()
|
||||
fireEvent.click(screen.getByRole('button', { name: en.installCloseCancels }))
|
||||
expect(actions.closeInstall).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
@@ -131,6 +131,38 @@ describe('sortPackages', () => {
|
||||
})
|
||||
|
||||
describe('PluginManagerController', () => {
|
||||
it.each(['before', 'after'] as const)('closes immediately and retries an early cancellation when acceptance arrives %s its reply', async (order) => {
|
||||
const install = deferred<ReturnType<typeof ok<ChangeResult>>>()
|
||||
const firstCancel = deferred<ReturnType<typeof ok<{ status: 'not-running' }>>>()
|
||||
const stopped = deferred<ReturnType<typeof ok<{ status: 'cancelled' }>>>()
|
||||
const { face, state, controller, plugins, started } = bench({
|
||||
installBundle: vi.fn().mockReturnValue(install.promise),
|
||||
cancelInstall: vi.fn().mockReturnValueOnce(firstCancel.promise).mockReturnValueOnce(stopped.promise),
|
||||
})
|
||||
face.openInstall()
|
||||
face.editInstallSpec('slow')
|
||||
face.runInstall()
|
||||
const requestId = await started()
|
||||
face.closeInstall()
|
||||
expect(state().install).toMatchObject({ open: false, phase: 'cancelling', requestId })
|
||||
expect(plugins.cancelInstall).toHaveBeenCalledExactlyOnceWith(requestId)
|
||||
face.openInstall()
|
||||
face.runInstall()
|
||||
expect(state().install).toMatchObject({ open: true, requestId, spec: 'slow' })
|
||||
expect(plugins.installBundle).toHaveBeenCalledOnce()
|
||||
if (order === 'before') controller.installProgress({ requestId, phase: 'installing' })
|
||||
firstCancel.resolve(ok({ status: 'not-running' }))
|
||||
if (order === 'after') {
|
||||
await vi.waitFor(() => { expect(state().install.failure?.cancelUnconfirmed).toBe(true) })
|
||||
controller.installProgress({ requestId, phase: 'installing' })
|
||||
}
|
||||
await vi.waitFor(() => { expect(plugins.cancelInstall).toHaveBeenCalledTimes(2) })
|
||||
expect(state().install.phase).toBe('cancelling')
|
||||
stopped.resolve(ok({ status: 'cancelled' }))
|
||||
await vi.waitFor(() => { expect(state().install).toMatchObject({ open: true, phase: 'idle', spec: 'slow' }) })
|
||||
install.resolve(ok({ ...failed(), application: 'cancelled' }))
|
||||
})
|
||||
|
||||
it('starts idle, reads the inventory then the bundles and entries on first use, and folds concurrent loads', async () => {
|
||||
const gate = deferred<ReturnType<typeof ok<BundleInfo[]>>>()
|
||||
const { plugins, inventory, face, state, controller } = bench({
|
||||
@@ -404,11 +436,6 @@ describe('PluginManagerController', () => {
|
||||
face.editInstallSpec('slow')
|
||||
face.runInstall()
|
||||
const requestId = await started()
|
||||
// Before the Host acknowledges the run there is nothing to stop, and the dialog cannot close.
|
||||
face.cancelInstall()
|
||||
face.closeInstall()
|
||||
expect(plugins.cancelInstall).not.toHaveBeenCalled()
|
||||
expect(state().install).toMatchObject({ open: true, phase: 'starting' })
|
||||
controller.installProgress({ requestId, phase: 'installing' })
|
||||
face.cancelInstall()
|
||||
face.cancelInstall()
|
||||
@@ -454,7 +481,7 @@ describe('PluginManagerController', () => {
|
||||
const requestId = await started()
|
||||
controller.installProgress({ requestId, phase: 'installing' })
|
||||
face.cancelInstall()
|
||||
await vi.waitFor(() => { expect(state().install.phase).toBe(status === 'too-late' ? 'applying' : 'running') })
|
||||
await vi.waitFor(() => { expect(state().install.phase).toBe(status === 'too-late' ? 'applying' : 'unconfirmed') })
|
||||
expect(plugins.cancelInstall).toHaveBeenCalledOnce()
|
||||
// A stop the Host did not confirm says so over the running screen, with the transport's words when it has them.
|
||||
expect(state().install.failure).toEqual(
|
||||
@@ -467,33 +494,119 @@ describe('PluginManagerController', () => {
|
||||
expect(state().notice).toEqual({ kind: 'cancelled', seq: 1 })
|
||||
})
|
||||
|
||||
it.each(['cancelled', 'too-late'] as const)('closes the dialog once the Host confirms the stop its close control asked for, and stays on %s', async (status) => {
|
||||
it.each(['cancelled', 'too-late', 'offline'] as const)('keeps a hidden installation recoverable when cancellation answers %s', async (status) => {
|
||||
const pending = deferred<ReturnType<typeof ok<ChangeResult>>>()
|
||||
const { plugins, face, state, controller, started } = bench({
|
||||
installBundle: vi.fn().mockReturnValue(pending.promise),
|
||||
cancelInstall: vi.fn().mockResolvedValue(ok({ status })),
|
||||
cancelInstall: vi.fn().mockResolvedValue(status === 'offline' ? refused('gateway/internal', 'offline') : ok({ status })),
|
||||
})
|
||||
// Before a run exists there is nothing to stop, and the dialog stays as it is.
|
||||
face.openInstall()
|
||||
face.cancelInstallAndClose()
|
||||
expect(state().install).toMatchObject({ open: true, phase: 'idle' })
|
||||
face.editInstallSpec('slow')
|
||||
face.runInstall()
|
||||
const requestId = await started()
|
||||
controller.installProgress({ requestId, phase: 'installing' })
|
||||
face.cancelInstallAndClose()
|
||||
expect(state().install.phase).toBe('cancelling')
|
||||
face.closeInstall()
|
||||
expect(state().install).toMatchObject({ open: false, phase: 'cancelling', requestId })
|
||||
if (status === 'cancelled') {
|
||||
await vi.waitFor(() => { expect(state().install).toMatchObject({ open: false, phase: 'idle', spec: '' }) })
|
||||
await vi.waitFor(() => { expect(state().install).toMatchObject({ open: false, phase: 'idle', spec: 'slow' }) })
|
||||
expect(state().notice).toEqual({ kind: 'cancelled', seq: 1 })
|
||||
} else {
|
||||
await vi.waitFor(() => { expect(state().install.phase).toBe('applying') })
|
||||
expect(state().install.open).toBe(true)
|
||||
await vi.waitFor(() => { expect(state().install.phase).toBe(status === 'too-late' ? 'applying' : 'unconfirmed') })
|
||||
expect(state().install.open).toBe(false)
|
||||
expect(state().notice).toMatchObject({ kind: 'install', outcome: status === 'too-late' ? 'applying' : 'unconfirmed' })
|
||||
face.openInstall()
|
||||
expect(state().install).toMatchObject({ open: true, requestId, spec: 'slow' })
|
||||
}
|
||||
expect(plugins.cancelInstall).toHaveBeenCalledExactlyOnceWith(requestId)
|
||||
pending.resolve(ok({ ...failed(), application: 'cancelled' }))
|
||||
})
|
||||
|
||||
it.each(['done', 'failed'] as const)('keeps a hidden %s result available without reopening the dialog', async (phase) => {
|
||||
const pending = deferred<ReturnType<typeof ok<ChangeResult>>>()
|
||||
const { face, state, controller, plugins, started } = bench({ installBundle: vi.fn().mockReturnValue(pending.promise) })
|
||||
face.openInstall()
|
||||
face.editInstallSpec('slow')
|
||||
face.runInstall()
|
||||
const requestId = await started()
|
||||
controller.installProgress({ requestId, phase: 'applying' })
|
||||
face.closeInstall()
|
||||
expect(state().install).toMatchObject({ open: false, phase: 'applying', requestId })
|
||||
expect(plugins.cancelInstall).not.toHaveBeenCalled()
|
||||
pending.resolve(ok(phase === 'done' ? { ...APPLIED, bundle: 'slow' } : failed()))
|
||||
await vi.waitFor(() => { expect(state().install.phase).toBe(phase) })
|
||||
expect(state().install.open).toBe(false)
|
||||
expect(state().notice).toMatchObject({ kind: 'install', outcome: phase })
|
||||
face.openInstall()
|
||||
expect(state().install).toMatchObject({ open: true, phase, requestId })
|
||||
face.closeInstall()
|
||||
face.openInstall()
|
||||
expect(state().install).toMatchObject({ open: true, phase: 'idle', spec: '' })
|
||||
})
|
||||
|
||||
it('retries a premature cancellation when output arrives without a start notification', async () => {
|
||||
const pending = deferred<ReturnType<typeof ok<ChangeResult>>>()
|
||||
const cancellation = deferred<ReturnType<typeof ok<{ status: 'cancelled' }>>>()
|
||||
const { face, state, controller, plugins, started } = bench({
|
||||
installBundle: vi.fn().mockReturnValue(pending.promise),
|
||||
cancelInstall: vi.fn().mockResolvedValueOnce(ok({ status: 'not-running' })).mockReturnValueOnce(cancellation.promise),
|
||||
})
|
||||
face.openInstall()
|
||||
face.editInstallSpec('slow')
|
||||
face.runInstall()
|
||||
const requestId = await started()
|
||||
face.closeInstall()
|
||||
await vi.waitFor(() => { expect(state().install.failure?.cancelUnconfirmed).toBe(true) })
|
||||
controller.appendLog({ requestId, jobId: 'j1', argv: ['pnpm', 'add', 'slow'], cwd: '/p', stream: 'stdout', text: 'Waiting for download' })
|
||||
expect(plugins.cancelInstall).toHaveBeenCalledTimes(2)
|
||||
expect(state().install).toMatchObject({ open: false, phase: 'cancelling', runs: [{ output: 'Waiting for download' }] })
|
||||
cancellation.resolve(ok({ status: 'cancelled' }))
|
||||
await vi.waitFor(() => { expect(state().install.phase).toBe('idle') })
|
||||
pending.resolve(ok({ ...failed(), application: 'cancelled' }))
|
||||
})
|
||||
|
||||
it('retains cancellation intent after a transport failure before the Host accepts installation', async () => {
|
||||
const pending = deferred<ReturnType<typeof ok<ChangeResult>>>()
|
||||
const { face, state, controller, plugins, started } = bench({
|
||||
installBundle: vi.fn().mockReturnValue(pending.promise),
|
||||
cancelInstall: vi.fn().mockResolvedValueOnce(refused('gateway/internal', 'offline')).mockResolvedValueOnce(ok({ status: 'cancelled' })),
|
||||
})
|
||||
face.openInstall()
|
||||
face.editInstallSpec('slow')
|
||||
face.runInstall()
|
||||
const requestId = await started()
|
||||
face.closeInstall()
|
||||
await vi.waitFor(() => { expect(state().install).toMatchObject({ open: false, phase: 'unconfirmed' }) })
|
||||
controller.installProgress({ requestId, phase: 'installing' })
|
||||
await vi.waitFor(() => { expect(state().install.phase).toBe('idle') })
|
||||
expect(plugins.cancelInstall).toHaveBeenCalledTimes(2)
|
||||
expect(state().notice).toMatchObject({ kind: 'cancelled' })
|
||||
pending.resolve(ok({ ...failed(), application: 'cancelled' }))
|
||||
})
|
||||
|
||||
it('keeps an in-flight cancellation authoritative when the installation response is lost', async () => {
|
||||
const pending = deferred<ReturnType<typeof refused>>()
|
||||
const cancellation = deferred<ReturnType<typeof ok<{ status: 'cancelled' }>>>()
|
||||
const { face, state, plugins, started } = bench({
|
||||
installBundle: vi.fn().mockReturnValue(pending.promise),
|
||||
cancelInstall: vi.fn().mockReturnValue(cancellation.promise),
|
||||
})
|
||||
face.openInstall()
|
||||
face.editInstallSpec('slow')
|
||||
face.runInstall()
|
||||
const requestId = await started()
|
||||
face.closeInstall()
|
||||
pending.resolve(refused('gateway/internal', 'offline'))
|
||||
await vi.waitFor(() => { expect(state().install).toMatchObject({ open: false, phase: 'unconfirmed', requestId }) })
|
||||
face.openInstall()
|
||||
face.runInstall()
|
||||
face.cancelInstall()
|
||||
expect(plugins.installBundle).toHaveBeenCalledOnce()
|
||||
expect(plugins.cancelInstall).toHaveBeenCalledOnce()
|
||||
cancellation.resolve(ok({ status: 'cancelled' }))
|
||||
await vi.waitFor(() => { expect(state().install.phase).toBe('idle') })
|
||||
expect(state().install.open).toBe(true)
|
||||
})
|
||||
|
||||
it.each([false, true])('drops a stop the Host confirms once the run settled, or after disposal (%s)', async (dispose) => {
|
||||
const answer = deferred<ReturnType<typeof ok<ChangeResult>>>()
|
||||
const cancellation = deferred<ReturnType<typeof ok<{ status: 'cancelled' }>>>()
|
||||
@@ -668,12 +781,16 @@ describe('PluginManagerController', () => {
|
||||
expect(state().install.runs).toEqual([])
|
||||
expect(state().install.failure).toEqual({ reason: 'ERR_PNPM', code: 'operation-error', kind: 'network' })
|
||||
expect(state().install.subject).toEqual({ spec: 'x', ...INSPECTED })
|
||||
// A refused answer, not a failed change, keeps the transport's words and settles the run without an exit code.
|
||||
// A lost answer cannot establish whether the Host stopped the process.
|
||||
await installing()
|
||||
controller.appendLog({ requestId, jobId: 'j', argv, cwd: '/p', stream: 'stderr', text: 'streamed' })
|
||||
answer(refused('gateway/internal', 'offline'))
|
||||
await vi.waitFor(() => { expect(state().install.failure).toEqual({ reason: 'offline' }) })
|
||||
expect(state().install.runs).toEqual([{ jobId: 'j', command: 'pnpm add x', cwd: '/p', output: 'streamed', exitCode: null }])
|
||||
await vi.waitFor(() => { expect(state().install).toMatchObject({ phase: 'unconfirmed', failure: { reason: 'offline', cancelUnconfirmed: true } }) })
|
||||
expect(state().install.runs).toEqual([{ jobId: 'j', command: 'pnpm add x', cwd: '/p', output: 'streamed' }])
|
||||
face.runInstall()
|
||||
expect(plugins.installBundle).toHaveBeenCalledTimes(2)
|
||||
face.cancelInstall()
|
||||
await vi.waitFor(() => { expect(state().install.phase).toBe('idle') })
|
||||
// A pnpm failure settles the open run with the code the answer names.
|
||||
await installing()
|
||||
controller.appendLog({ requestId, jobId: 'j', argv, cwd: '/p', stream: 'stdout', text: 'Done' })
|
||||
|
||||
Reference in new issue
Block a user