ng-qcauto
Version:
Effortless, stable test IDs for Angular apps. Automatically injects data-qcauto attributes for QA and test automation teams without cluttering templates.
517 lines (490 loc) • 16.1 kB
JavaScript
function hashBase36(input) {
let h1 = 0x811c9dc5, h2 = 0x811c9dc5;
for (let i = 0; i < input.length; i++) {
const c = input.charCodeAt(i);
h1 ^= c;
h1 = Math.imul(h1, 0x01000193);
h2 ^= (c << 1);
h2 = Math.imul(h2, 0x01000193);
}
const mixed = (h1 ^ (h2 >>> 7)) >>> 0;
return mixed.toString(36);
}
// qc-dompath.util.ts
function domPathWithinHost(el, host) {
const parts = [];
let node = el;
while (node && node !== host && node.nodeType === Node.ELEMENT_NODE) {
const tag = node.tagName.toLowerCase();
// nth-of-type among element siblings with the same tag (more stable than nth-child)
let index = 1;
let sib = node.previousElementSibling;
while (sib) {
if (sib.tagName.toLowerCase() === tag)
index++;
sib = sib.previousElementSibling;
}
parts.push(`${tag}:${index}`);
node = node.parentElement;
}
return parts.reverse().join('>');
}
// qc-auto-global.ts
// Current version of the QC Auto configuration
const QC_AUTO_VERSION = '2.0.8';
const VERSION_KEY = 'qcAuto-version';
function loadConfigFromStorage() {
const getArray = (key) => {
const raw = localStorage.getItem(key);
try {
return raw ? JSON.parse(raw) : [];
}
catch {
return [];
}
};
const clickToCopy = localStorage.getItem('qcAuto-clickToCopy');
return {
tags: getArray('qcAuto-tags'),
classes: getArray('qcAuto-classes'),
ids: getArray('qcAuto-ids'),
clickToCopy: clickToCopy === 'true',
};
}
function ensureDefaults() {
const storedVersion = localStorage.getItem(VERSION_KEY);
// If version doesn't match or doesn't exist, clear all old qcAuto data
if (storedVersion !== QC_AUTO_VERSION) {
console.log(`QC Auto: Version mismatch (stored: ${storedVersion}, current: ${QC_AUTO_VERSION}). Clearing old configuration...`);
// Clear all qcAuto-related items from localStorage
const keysToRemove = [];
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key && key.startsWith('qcAuto-')) {
keysToRemove.push(key);
}
}
keysToRemove.forEach(key => localStorage.removeItem(key));
// Set defaults for the new version
const defaultTags = [
'a', 'button', 'input', 'textarea', 'select', 'form',
'label', 'option', 'img'
];
localStorage.setItem('qcAuto-tags', JSON.stringify(defaultTags));
localStorage.setItem('qcAuto-classes', JSON.stringify([]));
localStorage.setItem('qcAuto-ids', JSON.stringify([]));
localStorage.setItem('qcAuto-clickToCopy', 'false');
// Store the current version
localStorage.setItem(VERSION_KEY, QC_AUTO_VERSION);
console.log(`QC Auto: Configuration initialized with version ${QC_AUTO_VERSION}`);
}
}
let CONFIG = { tags: [], classes: [], ids: [], clickToCopy: false };
let mutationObserver = null;
function getCurrentRoutePath() {
const path = window.location.pathname;
// Clean up the path: remove leading/trailing slashes, replace slashes with underscores
return path
.replace(/^\/+|\/+$/g, '') // Remove leading/trailing slashes
.replace(/\//g, '_') // Replace slashes with underscores
.replace(/[^a-zA-Z0-9_-]/g, '') || 'home'; // Remove special chars, default to 'home'
}
function initQcAutoGlobal() {
ensureDefaults();
CONFIG = loadConfigFromStorage();
console.log('QC-Auto initialized with config:', CONFIG);
// Inject styles for modal and toast
injectModalStyles();
// Setup keyboard shortcut listener (Ctrl+Q)
setupKeyboardShortcut();
// Setup click-to-copy if enabled
if (CONFIG.clickToCopy) {
setupClickToCopy();
}
// Initial scan
document.querySelectorAll('*').forEach(el => {
assignQcId(el);
});
// Watch DOM changes
mutationObserver = new MutationObserver(muts => {
muts.forEach(m => {
m.addedNodes.forEach(node => {
if (node instanceof Element) {
if (matchesTarget(node)) {
assignQcId(node);
}
node.querySelectorAll?.('*').forEach(el => {
assignQcId(el);
});
}
});
});
});
mutationObserver.observe(document.body, { childList: true, subtree: true });
}
function matchesTarget(el) {
const tag = el.tagName.toLowerCase();
if (CONFIG.tags?.includes(tag))
return true;
if (CONFIG.classes?.some(cls => el.classList.contains(cls)))
return true;
if (CONFIG.ids?.includes(el.id))
return true;
return false;
}
function assignQcId(el) {
if (el.hasAttribute('data-qcauto'))
return;
if (!matchesTarget(el))
return;
const routePath = getCurrentRoutePath();
const key = el.getAttribute('data-qc-key');
let basis;
if (key) {
basis = `${routePath}|${el.tagName}|${key}`;
}
else {
const path = domPathWithinHost(el, document.body);
basis = `${routePath}|${el.tagName}|${el.id}|${Array.from(el.classList).join('.') || ''}|${path}`;
}
const id = el.id
? `qc_${routePath}_${el.tagName.toLowerCase()}_${el.id}`
: `qc_${routePath}_${el.tagName.toLowerCase()}_${hashBase36(basis)}`;
el.setAttribute('data-qcauto', id);
// Add cursor pointer if click-to-copy is enabled
if (CONFIG.clickToCopy) {
el.style.cursor = 'pointer';
}
}
// Click-to-Copy System
function setupClickToCopy() {
document.body.addEventListener('contextmenu', (e) => {
const target = e.target;
// Don't copy if clicking on modal elements
if (target.closest('.qc-config-modal'))
return;
// Find the closest element with data-qcauto attribute
const qcElement = target.closest('[data-qcauto]');
if (qcElement) {
const qcId = qcElement.getAttribute('data-qcauto');
if (qcId) {
e.preventDefault(); // Prevent context menu from showing
e.stopPropagation();
copyToClipboard(qcId);
}
}
}, true); // Use capture phase to catch events early
}
function copyToClipboard(text) {
navigator.clipboard.writeText(text).then(() => {
console.log('Copied to clipboard:', text);
showToast(`✓ ${text}`);
}).catch(err => {
console.error('Failed to copy:', err);
// Fallback method
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
document.body.removeChild(textarea);
showToast(`✓ ${text}`);
});
}
function showToast(message) {
const toast = document.createElement('div');
toast.className = 'qc-toast';
toast.textContent = message;
document.body.appendChild(toast);
setTimeout(() => toast.remove(), 2000);
}
// Keyboard Shortcut System (Ctrl+Q)
let keySequence = [];
let keySequenceTimeout = null;
function setupKeyboardShortcut() {
document.addEventListener('keydown', (e) => {
if (e.ctrlKey || e.metaKey) {
keySequence.push(e.key.toLowerCase());
// Clear timeout if exists
if (keySequenceTimeout) {
clearTimeout(keySequenceTimeout);
}
// Reset sequence after 1 second
keySequenceTimeout = setTimeout(() => {
keySequence = [];
}, 1000);
// Check if sequence matches Ctrl+Q
const sequenceStr = keySequence.join('');
if (sequenceStr.includes('q')) {
e.preventDefault();
openConfigModal();
keySequence = [];
}
}
});
}
// Configuration Modal
function openConfigModal() {
// Remove existing modal if any
const existing = document.querySelector('.qc-config-modal');
if (existing) {
existing.remove();
return;
}
const modal = document.createElement('div');
modal.className = 'qc-config-modal';
const currentConfig = loadConfigFromStorage();
modal.innerHTML = `
<div class="qc-modal-content">
<div class="qc-modal-header">
<h2>QC Auto Configuration</h2>
<button class="qc-modal-close">×</button>
</div>
<div class="qc-modal-body">
<div class="qc-form-group">
<label for="qc-tags">Tags (comma-separated):</label>
<input
type="text"
id="qc-tags"
placeholder="e.g., button, input, a"
value="${currentConfig.tags.join(', ')}"
/>
</div>
<div class="qc-form-group">
<label for="qc-classes">Classes (comma-separated):</label>
<input
type="text"
id="qc-classes"
placeholder="e.g., btn, form-control"
value="${currentConfig.classes.join(', ')}"
/>
</div>
<div class="qc-form-group">
<label for="qc-ids">IDs (comma-separated):</label>
<input
type="text"
id="qc-ids"
placeholder="e.g., submit-btn, user-form"
value="${currentConfig.ids.join(', ')}"
/>
</div>
<div class="qc-form-group qc-checkbox-group">
<label>
<input
type="checkbox"
id="qc-click-to-copy"
${currentConfig.clickToCopy ? 'checked' : ''}
/>
Enable Click-to-Copy QC IDs
</label>
</div>
</div>
<div class="qc-modal-footer">
<button class="qc-btn qc-btn-primary" id="qc-reload-btn">Save & Reload</button>
<button class="qc-btn qc-btn-secondary" id="qc-cancel-btn">Cancel</button>
</div>
</div>
`;
document.body.appendChild(modal);
injectModalStyles();
// Event listeners
modal.querySelector('.qc-modal-close')?.addEventListener('click', () => modal.remove());
modal.querySelector('#qc-cancel-btn')?.addEventListener('click', () => modal.remove());
modal.querySelector('#qc-reload-btn')?.addEventListener('click', () => {
saveConfigAndReload(modal);
});
// Close on outside click
modal.addEventListener('click', (e) => {
if (e.target === modal) {
modal.remove();
}
});
// Focus first input
setTimeout(() => {
modal.querySelector('#qc-tags')?.focus();
}, 100);
}
function saveConfigAndReload(modal) {
const tagsInput = modal.querySelector('#qc-tags').value;
const classesInput = modal.querySelector('#qc-classes').value;
const idsInput = modal.querySelector('#qc-ids').value;
const clickToCopy = modal.querySelector('#qc-click-to-copy').checked;
// Parse and save
const tags = tagsInput.split(',').map(s => s.trim()).filter(s => s);
const classes = classesInput.split(',').map(s => s.trim()).filter(s => s);
const ids = idsInput.split(',').map(s => s.trim()).filter(s => s);
localStorage.setItem('qcAuto-tags', JSON.stringify(tags));
localStorage.setItem('qcAuto-classes', JSON.stringify(classes));
localStorage.setItem('qcAuto-ids', JSON.stringify(ids));
localStorage.setItem('qcAuto-clickToCopy', String(clickToCopy));
console.log('Configuration saved:', { tags, classes, ids, clickToCopy });
// Reload the page
window.location.reload();
}
function injectModalStyles() {
if (document.getElementById('qc-auto-styles'))
return;
const style = document.createElement('style');
style.id = 'qc-auto-styles';
style.textContent = `
/* Force LTR direction and prevent color overrides */
.qc-config-modal,
.qc-config-modal * {
direction: ltr !important;
color: inherit !important;
}
.qc-config-modal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 999999;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
}
.qc-modal-content {
background: white;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
width: 90%;
max-width: 500px;
max-height: 90vh;
overflow-y: auto;
}
.qc-modal-header {
padding: 20px;
border-bottom: 1px solid #e0e0e0;
display: flex;
justify-content: space-between;
align-items: center;
}
.qc-modal-header h2 {
margin: 0;
font-size: 20px;
color: #333 !important;
}
.qc-modal-close {
background: none;
border: none;
font-size: 28px;
cursor: pointer;
color: #999 !important;
padding: 0;
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
}
.qc-modal-close:hover {
color: #333 !important;
}
.qc-modal-body {
padding: 20px;
}
.qc-form-group {
margin-bottom: 20px;
}
.qc-form-group label {
display: block;
margin-bottom: 8px;
font-weight: 600;
color: #333 !important;
font-size: 14px;
}
.qc-form-group input[type="text"] {
width: 100%;
padding: 10px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
box-sizing: border-box;
}
.qc-form-group input[type="text"]:focus {
outline: none;
border-color: #4CAF50 !important;
}
.qc-checkbox-group label {
display: flex;
align-items: center;
cursor: pointer;
font-weight: normal;
}
.qc-checkbox-group input[type="checkbox"] {
margin-right: 8px;
width: 18px;
height: 18px;
cursor: pointer;
}
.qc-modal-footer {
padding: 20px;
border-top: 1px solid #e0e0e0;
display: flex;
gap: 10px;
justify-content: flex-end;
}
.qc-btn {
padding: 10px 20px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
font-weight: 600;
transition: all 0.2s;
}
.qc-btn-primary {
background: #4CAF50 !important;
color: white !important;
}
.qc-btn-primary:hover {
background: #45a049 !important;
}
.qc-btn-secondary {
background: #f0f0f0 !important;
color: #333 !important;
}
.qc-btn-secondary:hover {
background: #e0e0e0 !important;
}
/* Toast notification */
.qc-toast {
position: fixed;
bottom: 20px;
right: 20px;
background: #323232 !important;
color: white !important;
padding: 12px 20px;
border-radius: 4px;
z-index: 1000000;
animation: qc-toast-in 0.3s ease-out;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
font-size: 14px;
}
@keyframes qc-toast-in {
from {
transform: translateY(100px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
`;
document.head.appendChild(style);
}
/*
* Public API Surface of ng-qcauto
*/
/**
* Generated bundle index. Do not edit.
*/
export { domPathWithinHost, hashBase36, initQcAutoGlobal };
//# sourceMappingURL=ng-qcauto.mjs.map