Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 36 additions & 2 deletions src/client/lib/GeneratorAcquisition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,16 @@ export class GeneratorAcquisition implements IDisposable {
public get yoCommandPath(): string | null {
const execName = (os.platform() === 'win32') ? 'yo.cmd' : 'yo';
const yoBinaryPath = path.join(this._ppagesGlobalPath, 'node_modules', ".bin", execName);
return fs.pathExistsSync(yoBinaryPath) ? yoBinaryPath : null;
const exists = fs.pathExistsSync(yoBinaryPath);

oneDSLoggerWrapper.getLogger().traceInfo('YoCommandPathCheck', {
execName,
yoBinaryPath,
exists,
ppagesGlobalPath: this._ppagesGlobalPath
});

return exists ? yoBinaryPath : null;
}

public constructor(context: ICliAcquisitionContext) {
Expand Down Expand Up @@ -104,6 +113,15 @@ export class GeneratorAcquisition implements IDisposable {
fs.writeFileSync(path.join(this._ppagesGlobalPath, "package.json"), JSON.stringify(packageJson), 'utf-8');

const child = this.npm(['install']);

oneDSLoggerWrapper.getLogger().traceInfo('PowerPagesGeneratorNpmInstall', {
exitCode: child.status,
error: child.error ? String(child.error) : null,
stderr: child.stderr ? child.stderr.toString() : null,
stdout: child.stdout ? child.stdout.toString() : null,
cliVersion: this._generatorVersion
});

if (child.error) {
oneDSLoggerWrapper.getLogger().traceError(
'PowerPagesGeneratorInstallComplete',
Expand All @@ -117,9 +135,25 @@ export class GeneratorAcquisition implements IDisposable {
args: [String(child.error)],
comment: ["{0} represents the error message returned from the exception"]
}));
return null;
} else {
oneDSLoggerWrapper.getLogger().traceInfo('PowerPagesGeneratorInstallComplete', { cliVersion: this._generatorVersion });
this._context.showInformationMessage(vscode.l10n.t('The Power Pages generator is ready for use in your VS Code extension!'));

// Verify that the yo command is actually available after installation
const installedYoPath = this.yoCommandPath;
if (installedYoPath) {
this._context.showInformationMessage(vscode.l10n.t('The Power Pages generator is ready for use in your VS Code extension!'));
return installedYoPath;
} else {
oneDSLoggerWrapper.getLogger().traceError(
'PowerPagesGeneratorYoPathNotFound',
'Yo command not found after installation',
{ name: 'PowerPagesGeneratorYoPathNotFound', message: 'Yo command not found after installation' } as Error,
{ cliVersion: this._generatorVersion }, {}
);
this._context.showErrorMessage(vscode.l10n.t('Power Pages generator installation completed but yo command is not available. Please try restarting VS Code.'));
return null;
}
}
}
return this.yoCommandPath
Expand Down
3 changes: 1 addition & 2 deletions src/client/power-pages/create/CreateCommandWrapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,8 @@ export function initializeGenerator(
cliContext: any
): void {
const generator = new GeneratorAcquisition(cliContext);
generator.ensureInstalled();
const yoCommandPath = generator.ensureInstalled();
context.subscriptions.push(generator);
const yoCommandPath = generator.yoCommandPath;
if (yoCommandPath) {
registerCreateCommands(context, yoCommandPath);
vscode.workspace
Expand Down
131 changes: 131 additions & 0 deletions src/client/test/Integration/CreateCommandWrapper.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
/*
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*/

import * as fs from 'fs-extra';
import * as path from 'path';
import * as vscode from 'vscode';
import { expect } from 'chai';
import * as sinon from 'sinon';
import { initializeGenerator } from '../../power-pages/create/CreateCommandWrapper';
import { ICliAcquisitionContext } from '../../lib/CliAcquisitionContext';

const repoRootDir = path.resolve(__dirname, '../../../..');
const outdir = path.resolve(repoRootDir, 'out');

class MockInitContext implements ICliAcquisitionContext {
private readonly _testBaseDir: string;
private readonly _infoMessages: string[];
private readonly _errorMessages: string[];

public constructor() {
this._testBaseDir = path.resolve(outdir, 'testInitOut');
fs.ensureDirSync(this._testBaseDir);
this._infoMessages = [];
this._errorMessages = [];
}

public get extensionPath(): string { return repoRootDir; }
public get globalStorageLocalPath(): string { return this._testBaseDir; }

public get infoMessages(): string[] { return this._infoMessages; }
public get errorMessages(): string[] { return this._errorMessages; }
public get noErrors(): boolean { return this._errorMessages.length === 0; }

public showInformationMessage(message: string, ...items: string[]): void {
this._infoMessages.push(message);
}

public showErrorMessage(message: string, ...items: string[]): void {
this._errorMessages.push(message);
}

public showCliPreparingMessage(version: string): void {
this._infoMessages.push(`Preparing generator (v${version})...`);
}

public showCliReadyMessage(): void {
this._infoMessages.push('The Power Pages generator is ready for use in your VS Code extension!');
}

public showCliInstallFailedError(err: string): void {
this._errorMessages.push(`Cannot install Power Pages generator: ${err}`)
}

public locDotnetNotInstalledOrInsufficient(): string {
return "npm must be installed";
}
}

describe('CreateCommandWrapper Integration', () => {
let mockContext: vscode.ExtensionContext;
let cliContext: MockInitContext;
let configUpdateStub: sinon.SinonStub;
let commandsRegisterStub: sinon.SinonStub;

beforeEach(() => {
cliContext = new MockInitContext();

// Create mock VS Code extension context
mockContext = {
subscriptions: []
} as any;

// Stub vscode.workspace.getConfiguration
configUpdateStub = sinon.stub();
const mockConfig = {
update: configUpdateStub
};
sinon.stub(vscode.workspace, 'getConfiguration').returns(mockConfig as any);

// Stub vscode.commands.registerCommand
commandsRegisterStub = sinon.stub(vscode.commands, 'registerCommand');
});

afterEach(() => {
fs.emptyDirSync(path.resolve(cliContext.globalStorageLocalPath));
sinon.restore();
});

it('should set generatorInstalled config only when generator is actually available', () => {
// Call initializeGenerator
initializeGenerator(mockContext, cliContext);

// Check if the config was properly set
const configCalls = configUpdateStub.getCalls();
const generatorInstalledCall = configCalls.find(call =>
call.args[0] === 'generatorInstalled' && call.args[1] === true);

const hasSuccessMessage = cliContext.infoMessages.some(msg =>
msg.includes('The Power Pages generator is ready for use'));

// This is the key assertion: if we show success message, config should be set
if (hasSuccessMessage) {
expect(generatorInstalledCall).to.not.be.undefined;
} else {
expect(generatorInstalledCall).to.be.undefined;
}
});

it('should register commands only when generator is available', () => {
// Call initializeGenerator
initializeGenerator(mockContext, cliContext);

// Check if commands were registered
const commandRegistrationCalls = commandsRegisterStub.getCalls();
const powerPagesCommands = commandRegistrationCalls.filter(call =>
call.args[0].includes('microsoft-powerapps-portals.'));

const configCalls = configUpdateStub.getCalls();
const generatorInstalledCall = configCalls.find(call =>
call.args[0] === 'generatorInstalled' && call.args[1] === true);

// Commands should be registered if and only if config is set
if (generatorInstalledCall) {
expect(powerPagesCommands.length).to.be.greaterThan(0);
} else {
expect(powerPagesCommands.length).to.equal(0);
}
});
});
123 changes: 123 additions & 0 deletions src/client/test/Integration/GeneratorAcquisition.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
/*
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*/

import * as fs from 'fs-extra';
import * as path from 'path';
import { GeneratorAcquisition } from '../../lib/GeneratorAcquisition';
import { ICliAcquisitionContext } from '../../lib/CliAcquisitionContext';
import { expect } from 'chai';

const repoRootDir = path.resolve(__dirname, '../../../..');
const outdir = path.resolve(repoRootDir, 'out');
const mockRootDir = path.resolve(outdir, 'testdata');

class MockGeneratorContext implements ICliAcquisitionContext {
private readonly _testBaseDir: string;
private readonly _infoMessages: string[];
private readonly _errorMessages: string[];

public constructor() {
this._testBaseDir = path.resolve(outdir, 'testGeneratorOut');
fs.ensureDirSync(this._testBaseDir);
this._infoMessages = [];
this._errorMessages = [];
}

public get extensionPath(): string { return mockRootDir; }
public get globalStorageLocalPath(): string { return this._testBaseDir; }

public get infoMessages(): string[] { return this._infoMessages; }
public get errorMessages(): string[] { return this._errorMessages; }
public get noErrors(): boolean { return this._errorMessages.length === 0; }

public showInformationMessage(message: string, ...items: string[]): void {
this._infoMessages.push(message);
}

public showErrorMessage(message: string, ...items: string[]): void {
this._errorMessages.push(message);
}

public showCliPreparingMessage(version: string): void {
this._infoMessages.push(`Preparing generator (v${version})...`);
}

public showCliReadyMessage(): void {
this._infoMessages.push('The Power Pages generator is ready for use in your VS Code extension!');
}

public showCliInstallFailedError(err: string): void {
this._errorMessages.push(`Cannot install Power Pages generator: ${err}`)
}

public locDotnetNotInstalledOrInsufficient(): string {
return "npm must be installed";
}
}

describe('GeneratorAcquisition', () => {
let acq: GeneratorAcquisition;
let spy: MockGeneratorContext;

before(() => {
const generatorDistDir = path.resolve(mockRootDir, 'dist', 'powerpages');
fs.ensureDirSync(generatorDistDir);
});

beforeEach(() => {
spy = new MockGeneratorContext();
acq = new GeneratorAcquisition(spy);
});

afterEach(() => {
fs.emptyDirSync(path.resolve(spy.globalStorageLocalPath));
});

it('should return null if yoCommandPath is not found after installation', () => {
// This test simulates the scenario where installation succeeds but yo command is not available
const result = acq.ensureInstalled();

// In the original code, this would return null even if showing success message
// After our fix, it should either return a valid path or null with proper error message
if (result === null) {
// If null is returned, ensure we have an appropriate error message
expect(spy.errorMessages.length).to.be.greaterThan(0);
} else {
// If a path is returned, it should be valid
expect(result).to.be.a('string');
expect(fs.existsSync(result)).to.be.true;
}
});

it('should provide proper logging for installation states', () => {
const result = acq.ensureInstalled();

// The function should either show success or error messages, not both
const hasSuccessMessage = spy.infoMessages.some(msg =>
msg.includes('The Power Pages generator is ready for use'));
const hasErrorMessage = spy.errorMessages.length > 0;

if (result !== null) {
// If installation succeeded, we should have success message
expect(hasSuccessMessage).to.be.true;
expect(hasErrorMessage).to.be.false;
} else {
// If installation failed, we should have error message
expect(hasErrorMessage).to.be.true;
}
});

it('should not return null when showing success message', () => {
const result = acq.ensureInstalled();

const hasSuccessMessage = spy.infoMessages.some(msg =>
msg.includes('The Power Pages generator is ready for use'));

// This is the key fix: if we show success message, result should not be null
if (hasSuccessMessage) {
expect(result).to.not.be.null;
}
});
});