The complete guide for building, extending, and testing Power-Toolkit. For installation see the README; for what each tab does, see the User Guide.
git clone https://github.com/khawatme/Power-Toolkit.git
cd Power-Toolkit
npm install
npm run dev # Chrome/Edge
npm run dev:firefox # Firefox
npm run build # Chrome/Edge → dist/extension/
npm run build:firefox # Firefox → dist-firefox/extension/
npm run build:all # Build both versions
npm test # Run all tests
npm run test:coverage # Run with coverage report
Power-Toolkit/
├── src/
│ ├── components/ # Tab components (e.g., InspectorTab.js)
│ ├── services/ # Business logic services
│ ├── helpers/ # Utility functions (modular)
│ ├── ui/ # UI factories and controls
│ ├── utils/ # Utilities
│ │ ├── builders/ # ODataQueryBuilder
│ │ ├── parsers/ # ErrorParser
│ │ ├── resolvers/ # EntityContextResolver
│ │ ├── testing/ # Testing utilities
│ │ └── ui/ # BusyIndicator, PreferencesHelper, ResultPanel
│ ├── constants/ # Configuration and constants
│ ├── core/ # Base classes and core infrastructure
│ ├── data/ # Static data (code snippets, etc.)
│ ├── assets/ # Styles, icons, and static assets
│ ├── App.js # Main application entry point
│ └── Main.js # Bootstrap and initialization
├── extension/
│ ├── manifest.json # Chrome/Edge manifest
│ ├── manifest.firefox.json # Firefox manifest
│ ├── background.js # Cross-browser background script
│ └── icons/ # Extension icons
├── tests/ # Comprehensive test suite
├── dist/ # Chrome/Edge build output
├── dist-firefox/ # Firefox build output
├── webpack.config.cjs # Webpack configuration
└── package.json # Dependencies and scripts
Adding a new tab to Power-Toolkit is straightforward thanks to the modular architecture. Follow these steps:
Create a new file in src/components/ (e.g., MyCustomTab.js):
/**
* @file MyCustomTab - Description of what your tab does
* @module components/MyCustomTab
*/
import { BaseComponent } from '../core/BaseComponent.js';
import { ICONS } from '../assets/Icons.js';
import { Config } from '../constants/index.js';
import { DataService } from '../services/DataService.js';
import { NotificationService } from '../services/NotificationService.js';
import { UIFactory } from '../ui/UIFactory.js';
import { escapeHtml } from '../helpers/dom.helpers.js';
export class MyCustomTab extends BaseComponent {
constructor() {
super('mycustomtab', 'My Custom', ICONS.myIcon); // id, label, icon
this.ui = {}; // Store DOM references
// Handler references for cleanup (IMPORTANT: Prevents memory leaks)
/** @private {Function|null} */ this._executeHandler = null;
/** @private {Function|null} */ this._inputKeydownHandler = null;
}
/**
* Render the tab's UI
* @returns {Promise<HTMLElement>} The root element
*/
async render() {
const container = document.createElement('div');
container.innerHTML = `
<div class="pdt-section">
<div class="pdt-section-header">
<h3>My Custom Feature</h3>
<p class="pdt-note">Description of what this tab does</p>
</div>
<div class="pdt-card">
<div class="pdt-input-group">
<label for="my-input">Input Label:</label>
<input type="text" id="my-input" placeholder="Enter something...">
</div>
<div class="pdt-button-group">
<button id="my-execute-btn" class="pdt-button primary">
Execute
</button>
</div>
</div>
<div id="my-result-container"></div>
</div>
`;
return container;
}
/**
* Post-render lifecycle hook - called after DOM is attached
* IMPORTANT: Store handlers as instance properties for cleanup in destroy()
* @param {HTMLElement} element - The root element
*/
postRender(element) {
// Cache DOM references
this.ui = {
executeBtn: element.querySelector('#my-execute-btn'),
inputField: element.querySelector('#my-input'),
resultContainer: element.querySelector('#my-result-container')
};
// Store handlers for cleanup
this._executeHandler = () => this._handleExecute();
this._inputKeydownHandler = (e) => {
if (e.key === 'Enter') {
e.preventDefault();
this._handleExecute();
}
};
// Attach listeners
this.ui.executeBtn?.addEventListener('click', this._executeHandler);
this.ui.inputField?.addEventListener('keydown', this._inputKeydownHandler);
}
/**
* Handle execute button click
* @private
*/
async _handleExecute() {
const input = this.ui.inputField?.value?.trim();
if (!input) {
NotificationService.show('Please enter a value', 'error');
return;
}
try {
// Show loading state
this.ui.executeBtn.disabled = true;
this.ui.executeBtn.textContent = 'Processing...';
// Your business logic here
const result = await DataService.retrieveRecord('account', input);
// Display results
this.ui.resultContainer.innerHTML = '';
this.ui.resultContainer.appendChild(
UIFactory.createCopyableCodeBlock(
JSON.stringify(result, null, 2),
'json'
)
);
NotificationService.show('Operation completed successfully', 'success');
} catch (error) {
NotificationService.show(`Error: ${error.message}`, 'error');
this.ui.resultContainer.innerHTML = `
<div class="pdt-card error">
<p><strong>Error:</strong> ${escapeHtml(error.message)}</p>
</div>
`;
} finally {
// Reset button state
this.ui.executeBtn.disabled = false;
this.ui.executeBtn.textContent = 'Execute';
}
}
/**
* Lifecycle hook for cleaning up event listeners
* CRITICAL: Always implement this to prevent memory leaks
*/
destroy() {
if (this.ui.executeBtn) {
this.ui.executeBtn.removeEventListener('click', this._executeHandler);
}
if (this.ui.inputField) {
this.ui.inputField.removeEventListener('keydown', this._inputKeydownHandler);
}
}
}
Open src/App.js and import your component:
import { MyCustomTab } from './components/MyCustomTab.js';
Then add it to the _registerComponents() method:
_registerComponents() {
// Existing components...
ComponentRegistry.register(new MyCustomTab());
// ... other components
}
Note: The tab metadata (id, label, icon) is defined in your component’s constructor via super(). The UIManager automatically discovers all registered components, so no separate tab array is needed.
Add your tab to getDefaultTabSettings() in src/core/Store.js so it appears in the tab list and can be toggled in Settings:
function getDefaultTabSettings() {
return [
// ... existing tabs
{ id: 'mycustomtab', visible: true, formOnly: false },
// ... utility components (settings, help, about) go last
];
}
If your tab needs custom messages or configuration, add them to src/constants/:
messages.js:
export const MESSAGES = {
// ... existing messages
MY_CUSTOM_TAB: {
successMessage: 'Operation completed successfully',
errorMessage: 'Failed to process request',
validationError: 'Invalid input provided'
}
};
If you need custom styles, add them to src/assets/style.css:
/* My Custom Tab Styles */
#my-result-container {
margin-top: 1rem;
max-height: 400px;
overflow-y: auto;
}
.my-custom-class {
/* Your custom styles */
}
npm run dev to start development modeAlways implement proper cleanup to prevent memory leaks!
// ✅ CORRECT Pattern
constructor() {
super('mytab', 'My Tab', ICONS.myIcon);
this.ui = {};
// Initialize ALL handler properties to null
/** @private {Function|null} */ this._myHandler = null;
}
postRender(element) {
// Store handler as instance property
this._myHandler = () => { /* handler code */ };
element.addEventListener('click', this._myHandler);
}
destroy() {
// Remove listener using stored reference
if (element) {
element.removeEventListener('click', this._myHandler);
}
}
// ❌ WRONG - Memory leak!
postRender(element) {
// Anonymous function - can't be removed later
element.addEventListener('click', () => { /* handler code */ });
}
Key Rules:
destroy() methoddestroy()BaseComponent for automatic lifecycle managementsuper(id, label, icon) with tab metadataasync render() to create and return HTMLElementpostRender(element) to cache DOM refs and attach event listenersdestroy() for cleanup (memory leak prevention)this.ui = {} during postRendersuper(id, label, icon), initialize this.ui = {} and handler properties to nullthis.ui, store handlers as instance properties, attach listenersNotificationService for user-facing errorsfinally blocksstyle.css:
.pdt-section for main containers.pdt-card for content cards.pdt-button for buttons (add primary for primary actions).pdt-input-group for form inputs.pdt-note for helper textUIFactory for common UI elements (code blocks, info grids, etc.)PerformanceHelpers.debounce())Power-Toolkit provides many helper utilities in src/helpers/:
import { escapeHtml, createElement } from '../helpers/dom.helpers.js';
import { isValidGuid, normalizeGuid } from '../helpers/index.js';
import { formatDisplayValue } from '../helpers/formatting.helpers.js';
import { copyToClipboard } from '../helpers/file.helpers.js';
// Usage
const safeText = escapeHtml(userInput);
const isValid = isValidGuid(recordId);
const displayText = formatDisplayValue(attribute.getValue(), attribute);
Power-Toolkit follows strict memory management patterns. Here are common patterns:
constructor() {
super('mytab', 'My Tab', ICONS.myIcon);
this.ui = {};
/** @private {Function|null} */ this._clickHandler = null;
}
postRender(element) {
this.ui.button = element.querySelector('#my-button');
this._clickHandler = () => { /* code */ };
this.ui.button?.addEventListener('click', this._clickHandler);
}
destroy() {
if (this.ui.button) {
this.ui.button.removeEventListener('click', this._clickHandler);
}
}
constructor() {
super('mytab', 'My Tab', ICONS.myIcon);
this.ui = {};
/** @private {Function|null} */ this._delegatedHandler = null;
}
postRender(element) {
this.ui.container = element.querySelector('#container');
this._delegatedHandler = (e) => {
const target = e.target.closest('.my-item');
if (target) { /* handle */ }
};
this.ui.container?.addEventListener('click', this._delegatedHandler);
}
destroy() {
if (this.ui.container) {
this.ui.container.removeEventListener('click', this._delegatedHandler);
}
}
import { debounce } from '../helpers/index.js';
constructor() {
super('mytab', 'My Tab', ICONS.myIcon);
this.ui = {};
/** @private {Function|null} */ this._debouncedSearch = null;
}
postRender(element) {
this.ui.searchInput = element.querySelector('#search-input');
this._debouncedSearch = debounce(() => {
// Search logic
}, 250);
this.ui.searchInput?.addEventListener('input', this._debouncedSearch);
}
destroy() {
if (this.ui.searchInput) {
this.ui.searchInput.removeEventListener('input', this._debouncedSearch);
}
}
constructor() {
super('mytab', 'My Tab', ICONS.myIcon);
this.ui = {};
/** @private {Function|null} */ this._globalHandler = null;
}
postRender(element) {
this._globalHandler = (e) => { /* code */ };
document.addEventListener('pdt:refresh', this._globalHandler);
}
destroy() {
// IMPORTANT: Remove document-level listeners!
document.removeEventListener('pdt:refresh', this._globalHandler);
}
constructor() {
super('mytab', 'My Tab', ICONS.myIcon);
this.ui = {};
/** @private {Function|null} */ this._inputHandler = null;
}
postRender(element) {
this.ui.inputs = [
element.querySelector('#input1'),
element.querySelector('#input2'),
element.querySelector('#input3')
];
this._inputHandler = () => this._updatePreview();
this.ui.inputs.forEach(input => {
input?.addEventListener('input', this._inputHandler);
});
}
destroy() {
this.ui.inputs?.forEach(input => {
if (input) {
input.removeEventListener('input', this._inputHandler);
}
});
}
Common Mistakes to Avoid:
// ❌ WRONG: Closure captures scope - memory leak
postRender(element) {
const data = this.getData();
const button = element.querySelector('#my-button');
button.addEventListener('click', () => {
console.log(data); // Captures 'data' and 'this'
});
}
// ✅ CORRECT: Use instance property
constructor() {
super('mytab', 'My Tab', ICONS.myIcon);
this.ui = {};
/** @private {Function|null} */ this._buttonHandler = null;
}
postRender(element) {
this.ui.button = element.querySelector('#my-button');
this._buttonHandler = () => {
console.log(this.getData()); // No closure leak
};
this.ui.button.addEventListener('click', this._buttonHandler);
}
Before submitting your tab:
destroy() method implemented with all event listener cleanupPower-Toolkit uses Vitest as its testing framework with comprehensive test coverage. All tests run in a simulated browser environment using happy-dom.
# Run all tests once
npm test
# Run tests in watch mode (re-runs on file changes)
npm run test:watch
# Run tests with coverage report
npm run test:coverage
# Run specific test file
npm test -- tests/ui/FilterGroupManager.test.js
# Run tests matching a pattern
npm test -- --grep "SmartValueInput"
Tests are organized to mirror the source code structure:
tests/
├── setup.js # Global test setup and mocks
├── components/ # Tab component tests
│ ├── InspectorTab.test.js
│ ├── WebApiExplorerTab.test.js
│ ├── FetchXmlTesterTab.test.js
│ └── ...
├── services/ # Service layer tests
│ ├── DataService.test.js
│ ├── MetadataService.test.js
│ ├── NotificationService.test.js
│ └── ...
├── helpers/ # Helper function tests
│ ├── dom.helpers.test.js
│ ├── string.helpers.test.js
│ └── ...
├── ui/ # UI component tests
│ ├── FilterGroupManager.test.js
│ ├── SmartValueInput.test.js
│ ├── FormControlFactory.test.js
│ └── ...
└── utils/ # Utility tests
├── ui/
│ ├── BusyIndicator.test.js
│ ├── PreferencesHelper.test.js
│ └── ResultPanel.test.js
└── parsers/
└── ...
/**
* @file Tests for MyComponent
* @module tests/components/MyComponent.test.js
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { MyComponent } from '../../src/components/MyComponent.js';
describe('MyComponent', () => {
let component;
beforeEach(() => {
vi.clearAllMocks();
component = new MyComponent();
});
afterEach(() => {
component?.cleanup?.();
});
describe('constructor', () => {
it('should initialize with correct defaults', () => {
expect(component.id).toBe('mycomponent');
expect(component.ui).toEqual({});
});
});
describe('render', () => {
it('should create container element', async () => {
const element = await component.render();
expect(element).toBeInstanceOf(HTMLElement);
expect(element.querySelector('.my-class')).toBeTruthy();
});
});
describe('_handleClick', () => {
it('should process data correctly', () => {
const result = component._handleClick('test');
expect(result).toBe('expected');
});
});
});
import { vi } from 'vitest';
// Mock a service before importing the component
vi.mock('../../src/services/DataService.js', () => ({
DataService: {
retrieveRecord: vi.fn().mockResolvedValue({ name: 'Test' }),
fetchRecords: vi.fn().mockResolvedValue([])
}
}));
// Import component after mocks
import { MyComponent } from '../../src/components/MyComponent.js';
import { DataService } from '../../src/services/DataService.js';
describe('MyComponent with mocked service', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should call DataService with correct params', async () => {
const component = new MyComponent();
await component._loadData('account');
expect(DataService.retrieveRecord).toHaveBeenCalledWith('account');
});
it('should handle service errors', async () => {
DataService.retrieveRecord.mockRejectedValue(new Error('API Error'));
const component = new MyComponent();
await expect(component._loadData()).rejects.toThrow('API Error');
});
});
describe('DOM interactions', () => {
let component;
let container;
beforeEach(async () => {
component = new MyComponent();
container = await component.render();
document.body.appendChild(container);
component.postRender(container);
});
afterEach(() => {
component.cleanup?.();
container?.remove();
});
it('should update UI on button click', () => {
const button = container.querySelector('#my-button');
const output = container.querySelector('#output');
button.click();
expect(output.textContent).toBe('Clicked!');
});
it('should handle input changes', () => {
const input = container.querySelector('#my-input');
input.value = 'test value';
input.dispatchEvent(new Event('input'));
expect(component.currentValue).toBe('test value');
});
});
describe('async operations', () => {
it('should show loading state during fetch', async () => {
// Delay the mock response
DataService.fetchRecords.mockImplementation(
() => new Promise(resolve => setTimeout(() => resolve([]), 100))
);
const component = new MyComponent();
const container = await component.render();
const loadPromise = component._loadData();
// Check loading state
expect(container.querySelector('.loading')).toBeTruthy();
await loadPromise;
// Check loading cleared
expect(container.querySelector('.loading')).toBeFalsy();
});
});
should show error when input is emptytests/components/MyNewTab.test.jsrender() outputpostRender() event bindingcleanup() methodnpm test -- tests/components/MyNewTab.test.jsnpm run test:coveragegit checkout -b feature/my-new-feature
git commit -m "feat(mytab): add my new feature"
git push origin feature/my-new-feature
Power-Toolkit uses Conventional Commits:
<type>(<scope>): <subject>
[optional body — wrap at 72 chars]
[optional footer, e.g. Fixes #123]
| Type | When |
|---|---|
feat |
New feature |
fix |
Bug fix |
docs |
Documentation only |
style |
Formatting, no code change |
refactor |
Restructure without behavior change |
test |
Tests only |
chore |
Maintenance, dependencies |
perf |
Performance improvement |
ci |
CI/CD changes |
Subject rules: imperative mood (“add”, not “added”), lowercase, no trailing period, under 50 characters.