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 RECOMMENDED_WSL_DISTRO_NAME = 'Ubuntu';
|
||||||
const WINDOWS_DISTRO_APPEAR_RETRY_DELAY_MS = 2_000;
|
const WINDOWS_DISTRO_APPEAR_RETRY_DELAY_MS = 2_000;
|
||||||
const WINDOWS_DISTRO_APPEAR_RETRY_ATTEMPTS = 6;
|
const WINDOWS_DISTRO_APPEAR_RETRY_ATTEMPTS = 6;
|
||||||
|
const RESTART_REQUIRED_PATTERNS = ['restart', 'reboot', 'перезагруз', 'требуется перезагрузка'];
|
||||||
|
|
||||||
class TmuxInstallCancelledError extends Error {
|
class TmuxInstallCancelledError extends Error {
|
||||||
constructor() {
|
constructor() {
|
||||||
|
|
@ -325,13 +326,28 @@ export class TmuxInstallerRunnerAdapter
|
||||||
return status;
|
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({
|
this.#setSnapshot({
|
||||||
phase: 'needs_restart',
|
phase: 'needs_restart',
|
||||||
strategy: 'wsl',
|
strategy: 'wsl',
|
||||||
message: 'Restart Windows before continuing with tmux setup',
|
message: 'Restart Windows before continuing with tmux setup',
|
||||||
detail:
|
detail:
|
||||||
status.wsl.statusDetail ??
|
elevationResult.detail ??
|
||||||
|
status.wsl?.statusDetail ??
|
||||||
'WSL was installed, but Windows still needs a restart before tmux setup can continue.',
|
'WSL was installed, but Windows still needs a restart before tmux setup can continue.',
|
||||||
error: null,
|
error: null,
|
||||||
canCancel: false,
|
canCancel: false,
|
||||||
|
|
@ -339,7 +355,7 @@ export class TmuxInstallerRunnerAdapter
|
||||||
inputPrompt: null,
|
inputPrompt: null,
|
||||||
inputSecret: false,
|
inputSecret: false,
|
||||||
});
|
});
|
||||||
return status;
|
return rebootStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!status.wsl?.wslInstalled) {
|
if (!status.wsl?.wslInstalled) {
|
||||||
|
|
@ -464,6 +480,11 @@ export class TmuxInstallerRunnerAdapter
|
||||||
return status;
|
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> {
|
async #runResolvedPlan(plan: TmuxInstallPlan, resetLogs = true): Promise<void> {
|
||||||
this.#setSnapshot({
|
this.#setSnapshot({
|
||||||
phase: 'preparing',
|
phase: 'preparing',
|
||||||
|
|
|
||||||
|
|
@ -467,4 +467,100 @@ describe('TmuxInstallerRunnerAdapter', () => {
|
||||||
expect(runner.getSnapshot().message).toContain('Finish Ubuntu setup');
|
expect(runner.getSnapshot().message).toContain('Finish Ubuntu setup');
|
||||||
expect(runner.getSnapshot().detail).toContain('Wait a moment, then click Re-check');
|
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;
|
version: 1 | 2 | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface WindowsOptionalFeatureState {
|
||||||
|
FeatureName?: string | null;
|
||||||
|
State?: string | null;
|
||||||
|
RestartRequired?: string | boolean | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WindowsOptionalFeatureProbe {
|
||||||
|
restartPending: boolean;
|
||||||
|
hasConfiguredWslFeature: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
type ExecFileCallback = (
|
type ExecFileCallback = (
|
||||||
error: Error | null,
|
error: Error | null,
|
||||||
stdout: string | Buffer,
|
stdout: string | Buffer,
|
||||||
|
|
@ -48,6 +59,11 @@ export interface TmuxWslProbeResult {
|
||||||
|
|
||||||
const MAX_BUFFER_BYTES = 1024 * 1024;
|
const MAX_BUFFER_BYTES = 1024 * 1024;
|
||||||
const WSL_NOT_AVAILABLE_DETAIL = 'WSL is not available on this Windows machine yet.';
|
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 {
|
export class TmuxWslService {
|
||||||
readonly #execFile: ExecFileLike;
|
readonly #execFile: ExecFileLike;
|
||||||
|
|
@ -64,11 +80,15 @@ export class TmuxWslService {
|
||||||
async probe(): Promise<TmuxWslProbeResult> {
|
async probe(): Promise<TmuxWslProbeResult> {
|
||||||
const statusProbe = await this.#run(['--status'], 4_000);
|
const statusProbe = await this.#run(['--status'], 4_000);
|
||||||
const distroListProbe = await this.#run(['--list', '--quiet'], 4_000);
|
const distroListProbe = await this.#run(['--list', '--quiet'], 4_000);
|
||||||
|
const featureProbe = await this.#queryWindowsOptionalFeatures();
|
||||||
const persistedPreferredDistro = await this.#preferenceStore.getPreferredDistro();
|
const persistedPreferredDistro = await this.#preferenceStore.getPreferredDistro();
|
||||||
const wslInstalled = statusProbe.exitCode === 0 || distroListProbe.exitCode === 0;
|
const wslInstalled =
|
||||||
const rebootRequired = this.#looksLikeRestartRequired(
|
statusProbe.exitCode === 0 ||
|
||||||
`${statusProbe.stdout}\n${statusProbe.stderr}`
|
distroListProbe.exitCode === 0 ||
|
||||||
);
|
featureProbe?.hasConfiguredWslFeature === true;
|
||||||
|
const rebootRequired =
|
||||||
|
featureProbe?.restartPending === true ||
|
||||||
|
this.#looksLikeRestartRequired(`${statusProbe.stdout}\n${statusProbe.stderr}`);
|
||||||
|
|
||||||
if (!wslInstalled) {
|
if (!wslInstalled) {
|
||||||
if (persistedPreferredDistro) {
|
if (persistedPreferredDistro) {
|
||||||
|
|
@ -438,7 +458,79 @@ export class TmuxWslService {
|
||||||
|
|
||||||
#looksLikeRestartRequired(output: string): boolean {
|
#looksLikeRestartRequired(output: string): boolean {
|
||||||
const lowered = output.toLowerCase();
|
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 {
|
#firstNonEmpty(...values: (string | null | undefined)[]): string {
|
||||||
|
|
|
||||||
|
|
@ -15,9 +15,18 @@ interface ExecResult {
|
||||||
stderr: string;
|
stderr: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface PersistedFeatureState {
|
||||||
|
featureName?: string | null;
|
||||||
|
state?: string | null;
|
||||||
|
restartRequired?: string | boolean | null;
|
||||||
|
}
|
||||||
|
|
||||||
interface PersistedElevationResult {
|
interface PersistedElevationResult {
|
||||||
ok?: boolean;
|
ok?: boolean;
|
||||||
detail?: string | null;
|
detail?: string | null;
|
||||||
|
restartRequired?: boolean | null;
|
||||||
|
featureStates?: PersistedFeatureState[] | null;
|
||||||
|
commandExitCode?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
type ExecFileCallback = (
|
type ExecFileCallback = (
|
||||||
|
|
@ -47,6 +56,8 @@ export interface WindowsElevatedStepResult {
|
||||||
| 'elevated_failed'
|
| 'elevated_failed'
|
||||||
| 'elevated_unknown_outcome';
|
| 'elevated_unknown_outcome';
|
||||||
detail: string | null;
|
detail: string | null;
|
||||||
|
restartRequired: boolean;
|
||||||
|
featureStates: PersistedFeatureState[];
|
||||||
resultFilePath: string | null;
|
resultFilePath: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -84,6 +95,8 @@ export class WindowsElevatedStepRunner {
|
||||||
return {
|
return {
|
||||||
outcome: persistedResult.ok ? 'elevated_succeeded' : 'elevated_failed',
|
outcome: persistedResult.ok ? 'elevated_succeeded' : 'elevated_failed',
|
||||||
detail: persistedResult.detail ?? null,
|
detail: persistedResult.detail ?? null,
|
||||||
|
restartRequired: persistedResult.restartRequired === true,
|
||||||
|
featureStates: persistedResult.featureStates ?? [],
|
||||||
resultFilePath,
|
resultFilePath,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -92,6 +105,8 @@ export class WindowsElevatedStepRunner {
|
||||||
return {
|
return {
|
||||||
outcome: 'elevated_cancelled',
|
outcome: 'elevated_cancelled',
|
||||||
detail: 'Administrator permission request was cancelled.',
|
detail: 'Administrator permission request was cancelled.',
|
||||||
|
restartRequired: false,
|
||||||
|
featureStates: [],
|
||||||
resultFilePath: null,
|
resultFilePath: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -103,6 +118,8 @@ export class WindowsElevatedStepRunner {
|
||||||
return {
|
return {
|
||||||
outcome: 'elevated_unknown_outcome',
|
outcome: 'elevated_unknown_outcome',
|
||||||
detail: this.#firstNonEmpty(result.stderr, result.stdout),
|
detail: this.#firstNonEmpty(result.stderr, result.stdout),
|
||||||
|
restartRequired: false,
|
||||||
|
featureStates: [],
|
||||||
resultFilePath: null,
|
resultFilePath: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -170,9 +187,14 @@ export class WindowsElevatedStepRunner {
|
||||||
'$ErrorActionPreference = "Stop"',
|
'$ErrorActionPreference = "Stop"',
|
||||||
`$resultFile = '${escapedResultFilePath}'`,
|
`$resultFile = '${escapedResultFilePath}'`,
|
||||||
`$wslArgs = @(${quotedArgs})`,
|
`$wslArgs = @(${quotedArgs})`,
|
||||||
'$result = @{ ok = $false; detail = $null }',
|
'$featureNames = @("Microsoft-Windows-Subsystem-Linux", "VirtualMachinePlatform")',
|
||||||
|
'$result = @{ ok = $false; detail = $null; restartRequired = $false; featureStates = @(); commandExitCode = $null }',
|
||||||
'try {',
|
'try {',
|
||||||
' & wsl.exe @wslArgs',
|
' & 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) {',
|
' if ($LASTEXITCODE -eq 0) {',
|
||||||
' $result.ok = $true',
|
' $result.ok = $true',
|
||||||
' $result.detail = "WSL core installation command completed."',
|
' $result.detail = "WSL core installation command completed."',
|
||||||
|
|
|
||||||
|
|
@ -174,4 +174,81 @@ describe('TmuxWslService', () => {
|
||||||
expect(result.preference?.source).toBe('default');
|
expect(result.preference?.source).toBe('default');
|
||||||
expect(preferenceStore.getPreferredDistroSync()).toBeNull();
|
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');
|
const resultFilePath = path.join(path.dirname(launcherScriptPath), 'result.json');
|
||||||
await fsp.writeFile(
|
await fsp.writeFile(
|
||||||
resultFilePath,
|
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'
|
'utf8'
|
||||||
);
|
);
|
||||||
callback(null, '', '');
|
callback(null, '', '');
|
||||||
|
|
@ -26,6 +37,8 @@ describe('WindowsElevatedStepRunner', () => {
|
||||||
|
|
||||||
expect(result.outcome).toBe('elevated_succeeded');
|
expect(result.outcome).toBe('elevated_succeeded');
|
||||||
expect(result.detail).toContain('completed');
|
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');
|
expect(result.resultFilePath).toContain('result.json');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -36,7 +49,18 @@ describe('WindowsElevatedStepRunner', () => {
|
||||||
const resultFilePath = path.join(path.dirname(launcherScriptPath), 'result.json');
|
const resultFilePath = path.join(path.dirname(launcherScriptPath), 'result.json');
|
||||||
await fsp.writeFile(
|
await fsp.writeFile(
|
||||||
resultFilePath,
|
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'
|
'utf8'
|
||||||
);
|
);
|
||||||
callback(null, '', '');
|
callback(null, '', '');
|
||||||
|
|
@ -48,6 +72,8 @@ describe('WindowsElevatedStepRunner', () => {
|
||||||
|
|
||||||
expect(result.outcome).toBe('elevated_succeeded');
|
expect(result.outcome).toBe('elevated_succeeded');
|
||||||
expect(result.detail).toContain('completed');
|
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 () => {
|
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.outcome).toBe('elevated_cancelled');
|
||||||
expect(result.detail).toContain('cancelled');
|
expect(result.detail).toContain('cancelled');
|
||||||
|
expect(result.restartRequired).toBe(false);
|
||||||
|
expect(result.featureStates).toEqual([]);
|
||||||
expect(result.resultFilePath).toBeNull();
|
expect(result.resultFilePath).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -90,6 +118,7 @@ describe('WindowsElevatedStepRunner', () => {
|
||||||
|
|
||||||
expect(result.outcome).toBe('elevated_unknown_outcome');
|
expect(result.outcome).toBe('elevated_unknown_outcome');
|
||||||
expect(result.detail).toContain('Требуемая операция выполнена успешно');
|
expect(result.detail).toContain('Требуемая операция выполнена успешно');
|
||||||
|
expect(result.restartRequired).toBe(false);
|
||||||
} finally {
|
} finally {
|
||||||
consoleWarnSpy.mockRestore();
|
consoleWarnSpy.mockRestore();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -58,13 +58,13 @@ export class TmuxInstallerBannerAdapter {
|
||||||
adapt(input: AdaptInput): TmuxInstallerBannerViewModel {
|
adapt(input: AdaptInput): TmuxInstallerBannerViewModel {
|
||||||
const status = input.status;
|
const status = input.status;
|
||||||
const snapshot = input.snapshot;
|
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 =
|
const visible =
|
||||||
snapshot.phase !== 'idle' ||
|
hasActiveInstallFlow || (snapshot.phase !== 'completed' && !input.loading && tmuxMissing);
|
||||||
(!input.loading && (status ? !status.effective.runtimeReady : true));
|
|
||||||
const title =
|
const title =
|
||||||
snapshot.phase === 'idle' && status?.effective.available && !status.effective.runtimeReady
|
snapshot.message &&
|
||||||
? 'tmux needs one more step'
|
|
||||||
: snapshot.message &&
|
|
||||||
(snapshot.phase === 'pending_external_elevation' ||
|
(snapshot.phase === 'pending_external_elevation' ||
|
||||||
snapshot.phase === 'waiting_for_external_step' ||
|
snapshot.phase === 'waiting_for_external_step' ||
|
||||||
snapshot.phase === 'needs_restart' ||
|
snapshot.phase === 'needs_restart' ||
|
||||||
|
|
@ -82,7 +82,7 @@ export class TmuxInstallerBannerAdapter {
|
||||||
status?.wsl?.statusDetail ??
|
status?.wsl?.statusDetail ??
|
||||||
'tmux improves persistent teammate reliability and cleaner recovery for long-running tasks.';
|
'tmux improves persistent teammate reliability and cleaner recovery for long-running tasks.';
|
||||||
const benefitsBody =
|
const benefitsBody =
|
||||||
status && !status.effective.runtimeReady ? formatTmuxOptionalBenefits(status.platform) : null;
|
status && !status.effective.available ? formatTmuxOptionalBenefits(status.platform) : null;
|
||||||
const runtimeReadyLabel = status
|
const runtimeReadyLabel = status
|
||||||
? status.effective.runtimeReady
|
? status.effective.runtimeReady
|
||||||
? 'Ready for persistent teammates'
|
? 'Ready for persistent teammates'
|
||||||
|
|
|
||||||
|
|
@ -174,7 +174,7 @@ describe('TmuxInstallerBannerAdapter', () => {
|
||||||
expect(result.showRefreshButton).toBe(true);
|
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 adapter = TmuxInstallerBannerAdapter.create();
|
||||||
|
|
||||||
const result = adapter.adapt({
|
const result = adapter.adapt({
|
||||||
|
|
@ -196,12 +196,40 @@ describe('TmuxInstallerBannerAdapter', () => {
|
||||||
detailsOpen: false,
|
detailsOpen: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.visible).toBe(true);
|
expect(result.visible).toBe(false);
|
||||||
expect(result.title).toBe('tmux needs one more step');
|
|
||||||
expect(result.locationLabel).toBe('Host runtime');
|
expect(result.locationLabel).toBe('Host runtime');
|
||||||
expect(result.runtimeReadyLabel).toBe('Installed, but not active yet');
|
expect(result.runtimeReadyLabel).toBe('Installed, but not active yet');
|
||||||
expect(result.versionLabel).toBe('tmux 3.4');
|
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', () => {
|
it('exposes installer input metadata for interactive privilege flows', () => {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue