fix(tmux): strengthen windows restart detection
This commit is contained in:
parent
44f4af1756
commit
51aac9b7a1
8 changed files with 392 additions and 27 deletions
|
|
@ -17,6 +17,7 @@ const RETRY_WITH_UPDATE_PATTERNS = ['unable to locate package', 'failed to fetch
|
|||
const RECOMMENDED_WSL_DISTRO_NAME = 'Ubuntu';
|
||||
const WINDOWS_DISTRO_APPEAR_RETRY_DELAY_MS = 2_000;
|
||||
const WINDOWS_DISTRO_APPEAR_RETRY_ATTEMPTS = 6;
|
||||
const RESTART_REQUIRED_PATTERNS = ['restart', 'reboot', 'перезагруз', 'требуется перезагрузка'];
|
||||
|
||||
class TmuxInstallCancelledError extends Error {
|
||||
constructor() {
|
||||
|
|
@ -325,13 +326,28 @@ export class TmuxInstallerRunnerAdapter
|
|||
return status;
|
||||
}
|
||||
|
||||
if (status.wsl?.rebootRequired) {
|
||||
const rebootRequired =
|
||||
elevationResult.restartRequired ||
|
||||
status.wsl?.rebootRequired ||
|
||||
this.#looksLikeRestartRequired(elevationResult.detail);
|
||||
if (rebootRequired) {
|
||||
const rebootStatus = status.wsl
|
||||
? {
|
||||
...status,
|
||||
wsl: {
|
||||
...status.wsl,
|
||||
rebootRequired: true,
|
||||
statusDetail: elevationResult.detail ?? status.wsl.statusDetail,
|
||||
},
|
||||
}
|
||||
: status;
|
||||
this.#setSnapshot({
|
||||
phase: 'needs_restart',
|
||||
strategy: 'wsl',
|
||||
message: 'Restart Windows before continuing with tmux setup',
|
||||
detail:
|
||||
status.wsl.statusDetail ??
|
||||
elevationResult.detail ??
|
||||
status.wsl?.statusDetail ??
|
||||
'WSL was installed, but Windows still needs a restart before tmux setup can continue.',
|
||||
error: null,
|
||||
canCancel: false,
|
||||
|
|
@ -339,7 +355,7 @@ export class TmuxInstallerRunnerAdapter
|
|||
inputPrompt: null,
|
||||
inputSecret: false,
|
||||
});
|
||||
return status;
|
||||
return rebootStatus;
|
||||
}
|
||||
|
||||
if (!status.wsl?.wslInstalled) {
|
||||
|
|
@ -464,6 +480,11 @@ export class TmuxInstallerRunnerAdapter
|
|||
return status;
|
||||
}
|
||||
|
||||
#looksLikeRestartRequired(value: string | null | undefined): boolean {
|
||||
const lowered = value?.toLowerCase() ?? '';
|
||||
return RESTART_REQUIRED_PATTERNS.some((pattern) => lowered.includes(pattern));
|
||||
}
|
||||
|
||||
async #runResolvedPlan(plan: TmuxInstallPlan, resetLogs = true): Promise<void> {
|
||||
this.#setSnapshot({
|
||||
phase: 'preparing',
|
||||
|
|
|
|||
|
|
@ -467,4 +467,100 @@ describe('TmuxInstallerRunnerAdapter', () => {
|
|||
expect(runner.getSnapshot().message).toContain('Finish Ubuntu setup');
|
||||
expect(runner.getSnapshot().detail).toContain('Wait a moment, then click Re-check');
|
||||
});
|
||||
|
||||
it('switches Windows WSL setup into needs_restart when the elevated step reports a localized reboot message', async () => {
|
||||
const presenter = createPresenter();
|
||||
const initialStatus = createBaseStatus({
|
||||
platform: 'win32',
|
||||
autoInstall: {
|
||||
supported: true,
|
||||
strategy: 'wsl',
|
||||
packageManagerLabel: 'WSL',
|
||||
requiresTerminalInput: false,
|
||||
requiresAdmin: true,
|
||||
requiresRestart: true,
|
||||
mayOpenExternalWindow: true,
|
||||
reasonIfUnsupported: null,
|
||||
manualHints: [],
|
||||
},
|
||||
wsl: {
|
||||
wslInstalled: false,
|
||||
rebootRequired: false,
|
||||
distroName: null,
|
||||
distroVersion: null,
|
||||
distroBootstrapped: false,
|
||||
innerPackageManager: null,
|
||||
tmuxAvailableInsideWsl: false,
|
||||
tmuxVersion: null,
|
||||
tmuxBinaryPath: null,
|
||||
statusDetail: 'WSL is not installed yet.',
|
||||
},
|
||||
});
|
||||
const refreshedStatus = createBaseStatus({
|
||||
platform: 'win32',
|
||||
autoInstall: initialStatus.autoInstall,
|
||||
wsl: {
|
||||
wslInstalled: true,
|
||||
rebootRequired: false,
|
||||
distroName: null,
|
||||
distroVersion: null,
|
||||
distroBootstrapped: false,
|
||||
innerPackageManager: null,
|
||||
tmuxAvailableInsideWsl: false,
|
||||
tmuxVersion: null,
|
||||
tmuxBinaryPath: null,
|
||||
statusDetail: 'WSL is available, but no Linux distribution is installed yet.',
|
||||
},
|
||||
});
|
||||
let statusCallCount = 0;
|
||||
const statusSource = {
|
||||
getStatus: vi.fn(async () => {
|
||||
statusCallCount += 1;
|
||||
return statusCallCount === 1 ? initialStatus : refreshedStatus;
|
||||
}),
|
||||
invalidateStatus: vi.fn(),
|
||||
};
|
||||
const runner = new TmuxInstallerRunnerAdapter(
|
||||
statusSource as never,
|
||||
presenter as never,
|
||||
{
|
||||
resolve: vi.fn(async () => {
|
||||
throw new Error('resolve() should not run before restart');
|
||||
}),
|
||||
} as never,
|
||||
{
|
||||
run: vi.fn(),
|
||||
cancel: vi.fn(),
|
||||
} as never,
|
||||
{
|
||||
run: vi.fn(),
|
||||
writeLine: vi.fn(),
|
||||
cancel: vi.fn(),
|
||||
} as never,
|
||||
{
|
||||
persistPreferredDistro: vi.fn(async () => undefined),
|
||||
} as never,
|
||||
{
|
||||
runWslCoreInstall: vi.fn(async () => ({
|
||||
outcome: 'elevated_succeeded',
|
||||
detail: 'WSL core installation command completed.',
|
||||
restartRequired: true,
|
||||
featureStates: [
|
||||
{
|
||||
featureName: 'VirtualMachinePlatform',
|
||||
state: 'EnablePending',
|
||||
restartRequired: 'Possible',
|
||||
},
|
||||
],
|
||||
resultFilePath: 'C:\\temp\\result.json',
|
||||
})),
|
||||
} as never
|
||||
);
|
||||
|
||||
await expect(runner.install()).resolves.toBeUndefined();
|
||||
|
||||
expect(runner.getSnapshot().phase).toBe('needs_restart');
|
||||
expect(runner.getSnapshot().message).toContain('Restart Windows');
|
||||
expect(runner.getSnapshot().detail).toContain('WSL core installation command completed');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -23,6 +23,17 @@ interface WslVerboseDistroEntry {
|
|||
version: 1 | 2 | null;
|
||||
}
|
||||
|
||||
interface WindowsOptionalFeatureState {
|
||||
FeatureName?: string | null;
|
||||
State?: string | null;
|
||||
RestartRequired?: string | boolean | null;
|
||||
}
|
||||
|
||||
interface WindowsOptionalFeatureProbe {
|
||||
restartPending: boolean;
|
||||
hasConfiguredWslFeature: boolean;
|
||||
}
|
||||
|
||||
type ExecFileCallback = (
|
||||
error: Error | null,
|
||||
stdout: string | Buffer,
|
||||
|
|
@ -48,6 +59,11 @@ export interface TmuxWslProbeResult {
|
|||
|
||||
const MAX_BUFFER_BYTES = 1024 * 1024;
|
||||
const WSL_NOT_AVAILABLE_DETAIL = 'WSL is not available on this Windows machine yet.';
|
||||
const WINDOWS_WSL_FEATURE_NAMES = ['Microsoft-Windows-Subsystem-Linux', 'VirtualMachinePlatform'];
|
||||
const POWERSHELL_FEATURE_QUERY = [
|
||||
'$features = Get-WindowsOptionalFeature -Online -FeatureName "Microsoft-Windows-Subsystem-Linux","VirtualMachinePlatform"',
|
||||
'$features | Select-Object FeatureName, State, RestartRequired | ConvertTo-Json -Compress',
|
||||
].join('; ');
|
||||
|
||||
export class TmuxWslService {
|
||||
readonly #execFile: ExecFileLike;
|
||||
|
|
@ -64,11 +80,15 @@ export class TmuxWslService {
|
|||
async probe(): Promise<TmuxWslProbeResult> {
|
||||
const statusProbe = await this.#run(['--status'], 4_000);
|
||||
const distroListProbe = await this.#run(['--list', '--quiet'], 4_000);
|
||||
const featureProbe = await this.#queryWindowsOptionalFeatures();
|
||||
const persistedPreferredDistro = await this.#preferenceStore.getPreferredDistro();
|
||||
const wslInstalled = statusProbe.exitCode === 0 || distroListProbe.exitCode === 0;
|
||||
const rebootRequired = this.#looksLikeRestartRequired(
|
||||
`${statusProbe.stdout}\n${statusProbe.stderr}`
|
||||
);
|
||||
const wslInstalled =
|
||||
statusProbe.exitCode === 0 ||
|
||||
distroListProbe.exitCode === 0 ||
|
||||
featureProbe?.hasConfiguredWslFeature === true;
|
||||
const rebootRequired =
|
||||
featureProbe?.restartPending === true ||
|
||||
this.#looksLikeRestartRequired(`${statusProbe.stdout}\n${statusProbe.stderr}`);
|
||||
|
||||
if (!wslInstalled) {
|
||||
if (persistedPreferredDistro) {
|
||||
|
|
@ -438,7 +458,79 @@ export class TmuxWslService {
|
|||
|
||||
#looksLikeRestartRequired(output: string): boolean {
|
||||
const lowered = output.toLowerCase();
|
||||
return lowered.includes('restart') || lowered.includes('reboot');
|
||||
return (
|
||||
lowered.includes('restart') ||
|
||||
lowered.includes('reboot') ||
|
||||
lowered.includes('перезагруз') ||
|
||||
lowered.includes('требуется перезагрузка')
|
||||
);
|
||||
}
|
||||
|
||||
async #queryWindowsOptionalFeatures(): Promise<WindowsOptionalFeatureProbe | null> {
|
||||
const result = await this.#execPowerShell(
|
||||
['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', POWERSHELL_FEATURE_QUERY],
|
||||
6_000
|
||||
);
|
||||
if (!result || result.exitCode !== 0 || !result.stdout.trim()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(result.stdout) as
|
||||
| WindowsOptionalFeatureState
|
||||
| WindowsOptionalFeatureState[];
|
||||
const features = Array.isArray(parsed) ? parsed : [parsed];
|
||||
const relevantFeatures = features.filter((feature) =>
|
||||
WINDOWS_WSL_FEATURE_NAMES.includes(feature.FeatureName ?? '')
|
||||
);
|
||||
if (relevantFeatures.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
restartPending: relevantFeatures.some((feature) =>
|
||||
String(feature.State ?? '')
|
||||
.toLowerCase()
|
||||
.includes('pending')
|
||||
),
|
||||
hasConfiguredWslFeature: relevantFeatures.some((feature) => {
|
||||
const state = String(feature.State ?? '').toLowerCase();
|
||||
return state.length > 0 && state !== 'disabled' && state !== 'disabledwithpayloadremoved';
|
||||
}),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async #execPowerShell(args: string[], timeout: number): Promise<ExecWslResult | null> {
|
||||
return new Promise((resolve) => {
|
||||
this.#execFile(
|
||||
'powershell.exe',
|
||||
args,
|
||||
{
|
||||
timeout,
|
||||
windowsHide: true,
|
||||
maxBuffer: MAX_BUFFER_BYTES,
|
||||
encoding: 'buffer',
|
||||
},
|
||||
(error, stdout, stderr) => {
|
||||
const errorCode =
|
||||
typeof error === 'object' && error !== null && 'code' in error
|
||||
? (error as NodeJS.ErrnoException).code
|
||||
: undefined;
|
||||
if (errorCode === 'ENOENT') {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
resolve({
|
||||
exitCode: typeof errorCode === 'number' ? errorCode : error ? 1 : 0,
|
||||
stdout: this.#decodeOutput(stdout),
|
||||
stderr: this.#decodeOutput(stderr) || (error instanceof Error ? error.message : ''),
|
||||
});
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#firstNonEmpty(...values: (string | null | undefined)[]): string {
|
||||
|
|
|
|||
|
|
@ -15,9 +15,18 @@ interface ExecResult {
|
|||
stderr: string;
|
||||
}
|
||||
|
||||
interface PersistedFeatureState {
|
||||
featureName?: string | null;
|
||||
state?: string | null;
|
||||
restartRequired?: string | boolean | null;
|
||||
}
|
||||
|
||||
interface PersistedElevationResult {
|
||||
ok?: boolean;
|
||||
detail?: string | null;
|
||||
restartRequired?: boolean | null;
|
||||
featureStates?: PersistedFeatureState[] | null;
|
||||
commandExitCode?: number | null;
|
||||
}
|
||||
|
||||
type ExecFileCallback = (
|
||||
|
|
@ -47,6 +56,8 @@ export interface WindowsElevatedStepResult {
|
|||
| 'elevated_failed'
|
||||
| 'elevated_unknown_outcome';
|
||||
detail: string | null;
|
||||
restartRequired: boolean;
|
||||
featureStates: PersistedFeatureState[];
|
||||
resultFilePath: string | null;
|
||||
}
|
||||
|
||||
|
|
@ -84,6 +95,8 @@ export class WindowsElevatedStepRunner {
|
|||
return {
|
||||
outcome: persistedResult.ok ? 'elevated_succeeded' : 'elevated_failed',
|
||||
detail: persistedResult.detail ?? null,
|
||||
restartRequired: persistedResult.restartRequired === true,
|
||||
featureStates: persistedResult.featureStates ?? [],
|
||||
resultFilePath,
|
||||
};
|
||||
}
|
||||
|
|
@ -92,6 +105,8 @@ export class WindowsElevatedStepRunner {
|
|||
return {
|
||||
outcome: 'elevated_cancelled',
|
||||
detail: 'Administrator permission request was cancelled.',
|
||||
restartRequired: false,
|
||||
featureStates: [],
|
||||
resultFilePath: null,
|
||||
};
|
||||
}
|
||||
|
|
@ -103,6 +118,8 @@ export class WindowsElevatedStepRunner {
|
|||
return {
|
||||
outcome: 'elevated_unknown_outcome',
|
||||
detail: this.#firstNonEmpty(result.stderr, result.stdout),
|
||||
restartRequired: false,
|
||||
featureStates: [],
|
||||
resultFilePath: null,
|
||||
};
|
||||
}
|
||||
|
|
@ -170,9 +187,14 @@ export class WindowsElevatedStepRunner {
|
|||
'$ErrorActionPreference = "Stop"',
|
||||
`$resultFile = '${escapedResultFilePath}'`,
|
||||
`$wslArgs = @(${quotedArgs})`,
|
||||
'$result = @{ ok = $false; detail = $null }',
|
||||
'$featureNames = @("Microsoft-Windows-Subsystem-Linux", "VirtualMachinePlatform")',
|
||||
'$result = @{ ok = $false; detail = $null; restartRequired = $false; featureStates = @(); commandExitCode = $null }',
|
||||
'try {',
|
||||
' & wsl.exe @wslArgs',
|
||||
' $result.commandExitCode = $LASTEXITCODE',
|
||||
' $features = @(Get-WindowsOptionalFeature -Online -FeatureName $featureNames | Select-Object FeatureName, State, RestartRequired)',
|
||||
' $result.featureStates = @($features | ForEach-Object { @{ featureName = $_.FeatureName; state = [string]$_.State; restartRequired = $_.RestartRequired } })',
|
||||
' $result.restartRequired = @($features | Where-Object { "$($_.State)" -like "*Pending*" }).Count -gt 0',
|
||||
' if ($LASTEXITCODE -eq 0) {',
|
||||
' $result.ok = $true',
|
||||
' $result.detail = "WSL core installation command completed."',
|
||||
|
|
|
|||
|
|
@ -174,4 +174,81 @@ describe('TmuxWslService', () => {
|
|||
expect(result.preference?.source).toBe('default');
|
||||
expect(preferenceStore.getPreferredDistroSync()).toBeNull();
|
||||
});
|
||||
|
||||
it('detects a reboot requirement from localized Windows output', async () => {
|
||||
const service = new TmuxWslService(
|
||||
createExecFileMock({
|
||||
'--status': {
|
||||
stdout:
|
||||
'Требуемая операция выполнена успешно. Чтобы сделанные изменения вступили в силу, следует перезагрузить систему.',
|
||||
},
|
||||
'--list --quiet': { stdout: '' },
|
||||
}),
|
||||
createPreferenceStore() as never
|
||||
);
|
||||
|
||||
const result = await service.probe();
|
||||
|
||||
expect(result.status.wslInstalled).toBe(true);
|
||||
expect(result.status.rebootRequired).toBe(true);
|
||||
expect(result.status.statusDetail).toContain('restart');
|
||||
});
|
||||
|
||||
it('detects a reboot requirement from pending Windows optional feature state', async () => {
|
||||
const execFileMock = (
|
||||
command: string,
|
||||
args: string[],
|
||||
_options: {
|
||||
timeout: number;
|
||||
windowsHide: boolean;
|
||||
maxBuffer: number;
|
||||
encoding: 'buffer';
|
||||
},
|
||||
callback: (error: Error | null, stdout: string | Buffer, stderr: string | Buffer) => void
|
||||
) => {
|
||||
if (command === 'powershell.exe') {
|
||||
callback(
|
||||
null,
|
||||
JSON.stringify([
|
||||
{
|
||||
FeatureName: 'Microsoft-Windows-Subsystem-Linux',
|
||||
State: 'EnablePending',
|
||||
RestartRequired: 'Possible',
|
||||
},
|
||||
{
|
||||
FeatureName: 'VirtualMachinePlatform',
|
||||
State: 'EnablePending',
|
||||
RestartRequired: 'Possible',
|
||||
},
|
||||
]),
|
||||
''
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const key = args.join(' ');
|
||||
if (key === '--status') {
|
||||
callback(null, 'ok', '');
|
||||
return;
|
||||
}
|
||||
if (key === '--list --quiet') {
|
||||
callback(null, '', '');
|
||||
return;
|
||||
}
|
||||
|
||||
callback(
|
||||
Object.assign(new Error(`Unexpected command: ${command} ${key}`), { code: 'EFAIL' }),
|
||||
'',
|
||||
''
|
||||
);
|
||||
};
|
||||
|
||||
const service = new TmuxWslService(execFileMock, createPreferenceStore() as never);
|
||||
|
||||
const result = await service.probe();
|
||||
|
||||
expect(result.status.wslInstalled).toBe(true);
|
||||
expect(result.status.rebootRequired).toBe(true);
|
||||
expect(result.status.statusDetail).toContain('restart');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -14,7 +14,18 @@ describe('WindowsElevatedStepRunner', () => {
|
|||
const resultFilePath = path.join(path.dirname(launcherScriptPath), 'result.json');
|
||||
await fsp.writeFile(
|
||||
resultFilePath,
|
||||
JSON.stringify({ ok: true, detail: 'WSL core installation command completed.' }),
|
||||
JSON.stringify({
|
||||
ok: true,
|
||||
detail: 'WSL core installation command completed.',
|
||||
restartRequired: false,
|
||||
featureStates: [
|
||||
{
|
||||
featureName: 'Microsoft-Windows-Subsystem-Linux',
|
||||
state: 'Enabled',
|
||||
restartRequired: false,
|
||||
},
|
||||
],
|
||||
}),
|
||||
'utf8'
|
||||
);
|
||||
callback(null, '', '');
|
||||
|
|
@ -26,6 +37,8 @@ describe('WindowsElevatedStepRunner', () => {
|
|||
|
||||
expect(result.outcome).toBe('elevated_succeeded');
|
||||
expect(result.detail).toContain('completed');
|
||||
expect(result.restartRequired).toBe(false);
|
||||
expect(result.featureStates[0]?.featureName).toBe('Microsoft-Windows-Subsystem-Linux');
|
||||
expect(result.resultFilePath).toContain('result.json');
|
||||
});
|
||||
|
||||
|
|
@ -36,7 +49,18 @@ describe('WindowsElevatedStepRunner', () => {
|
|||
const resultFilePath = path.join(path.dirname(launcherScriptPath), 'result.json');
|
||||
await fsp.writeFile(
|
||||
resultFilePath,
|
||||
`\uFEFF${JSON.stringify({ ok: true, detail: 'WSL core installation command completed.' })}`,
|
||||
`\uFEFF${JSON.stringify({
|
||||
ok: true,
|
||||
detail: 'WSL core installation command completed.',
|
||||
restartRequired: true,
|
||||
featureStates: [
|
||||
{
|
||||
featureName: 'VirtualMachinePlatform',
|
||||
state: 'EnablePending',
|
||||
restartRequired: 'Possible',
|
||||
},
|
||||
],
|
||||
})}`,
|
||||
'utf8'
|
||||
);
|
||||
callback(null, '', '');
|
||||
|
|
@ -48,6 +72,8 @@ describe('WindowsElevatedStepRunner', () => {
|
|||
|
||||
expect(result.outcome).toBe('elevated_succeeded');
|
||||
expect(result.detail).toContain('completed');
|
||||
expect(result.restartRequired).toBe(true);
|
||||
expect(result.featureStates[0]?.state).toBe('EnablePending');
|
||||
});
|
||||
|
||||
it('treats a missing result file plus cancel text as elevation cancellation', async () => {
|
||||
|
|
@ -66,6 +92,8 @@ describe('WindowsElevatedStepRunner', () => {
|
|||
|
||||
expect(result.outcome).toBe('elevated_cancelled');
|
||||
expect(result.detail).toContain('cancelled');
|
||||
expect(result.restartRequired).toBe(false);
|
||||
expect(result.featureStates).toEqual([]);
|
||||
expect(result.resultFilePath).toBeNull();
|
||||
});
|
||||
|
||||
|
|
@ -90,6 +118,7 @@ describe('WindowsElevatedStepRunner', () => {
|
|||
|
||||
expect(result.outcome).toBe('elevated_unknown_outcome');
|
||||
expect(result.detail).toContain('Требуемая операция выполнена успешно');
|
||||
expect(result.restartRequired).toBe(false);
|
||||
} finally {
|
||||
consoleWarnSpy.mockRestore();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -58,19 +58,19 @@ export class TmuxInstallerBannerAdapter {
|
|||
adapt(input: AdaptInput): TmuxInstallerBannerViewModel {
|
||||
const status = input.status;
|
||||
const snapshot = input.snapshot;
|
||||
const hasActiveInstallFlow =
|
||||
snapshot.phase !== 'idle' && snapshot.phase !== 'completed' && snapshot.phase !== 'cancelled';
|
||||
const tmuxMissing = status ? !status.effective.available : !input.loading;
|
||||
const visible =
|
||||
snapshot.phase !== 'idle' ||
|
||||
(!input.loading && (status ? !status.effective.runtimeReady : true));
|
||||
hasActiveInstallFlow || (snapshot.phase !== 'completed' && !input.loading && tmuxMissing);
|
||||
const title =
|
||||
snapshot.phase === 'idle' && status?.effective.available && !status.effective.runtimeReady
|
||||
? 'tmux needs one more step'
|
||||
: snapshot.message &&
|
||||
(snapshot.phase === 'pending_external_elevation' ||
|
||||
snapshot.phase === 'waiting_for_external_step' ||
|
||||
snapshot.phase === 'needs_restart' ||
|
||||
snapshot.phase === 'needs_manual_step')
|
||||
? snapshot.message
|
||||
: formatTmuxInstallerTitle(snapshot.phase);
|
||||
snapshot.message &&
|
||||
(snapshot.phase === 'pending_external_elevation' ||
|
||||
snapshot.phase === 'waiting_for_external_step' ||
|
||||
snapshot.phase === 'needs_restart' ||
|
||||
snapshot.phase === 'needs_manual_step')
|
||||
? snapshot.message
|
||||
: formatTmuxInstallerTitle(snapshot.phase);
|
||||
const primaryGuideUrl =
|
||||
status?.autoInstall.manualHints.find((hint) => typeof hint.url === 'string')?.url ?? null;
|
||||
const body =
|
||||
|
|
@ -82,7 +82,7 @@ export class TmuxInstallerBannerAdapter {
|
|||
status?.wsl?.statusDetail ??
|
||||
'tmux improves persistent teammate reliability and cleaner recovery for long-running tasks.';
|
||||
const benefitsBody =
|
||||
status && !status.effective.runtimeReady ? formatTmuxOptionalBenefits(status.platform) : null;
|
||||
status && !status.effective.available ? formatTmuxOptionalBenefits(status.platform) : null;
|
||||
const runtimeReadyLabel = status
|
||||
? status.effective.runtimeReady
|
||||
? 'Ready for persistent teammates'
|
||||
|
|
|
|||
|
|
@ -174,7 +174,7 @@ describe('TmuxInstallerBannerAdapter', () => {
|
|||
expect(result.showRefreshButton).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps the banner visible when tmux is installed but runtime is not ready yet', () => {
|
||||
it('hides the banner when tmux is already installed', () => {
|
||||
const adapter = TmuxInstallerBannerAdapter.create();
|
||||
|
||||
const result = adapter.adapt({
|
||||
|
|
@ -196,12 +196,40 @@ describe('TmuxInstallerBannerAdapter', () => {
|
|||
detailsOpen: false,
|
||||
});
|
||||
|
||||
expect(result.visible).toBe(true);
|
||||
expect(result.title).toBe('tmux needs one more step');
|
||||
expect(result.visible).toBe(false);
|
||||
expect(result.locationLabel).toBe('Host runtime');
|
||||
expect(result.runtimeReadyLabel).toBe('Installed, but not active yet');
|
||||
expect(result.versionLabel).toBe('tmux 3.4');
|
||||
expect(result.benefitsBody).toContain('With tmux in WSL');
|
||||
expect(result.benefitsBody).toBeNull();
|
||||
});
|
||||
|
||||
it('hides a completed installer banner once tmux is available', () => {
|
||||
const adapter = TmuxInstallerBannerAdapter.create();
|
||||
|
||||
const result = adapter.adapt({
|
||||
status: {
|
||||
...baseStatus,
|
||||
effective: {
|
||||
available: true,
|
||||
location: 'host',
|
||||
version: 'tmux 3.6a',
|
||||
binaryPath: '/opt/homebrew/bin/tmux',
|
||||
runtimeReady: true,
|
||||
detail: 'tmux is available for persistent teammates.',
|
||||
},
|
||||
},
|
||||
snapshot: {
|
||||
...idleSnapshot,
|
||||
phase: 'completed',
|
||||
strategy: 'homebrew',
|
||||
message: 'tmux installed',
|
||||
},
|
||||
loading: false,
|
||||
error: null,
|
||||
detailsOpen: false,
|
||||
});
|
||||
|
||||
expect(result.visible).toBe(false);
|
||||
});
|
||||
|
||||
it('exposes installer input metadata for interactive privilege flows', () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue