diff --git a/.eslintrc.json b/.eslintrc.json deleted file mode 100644 index d092ad6..0000000 --- a/.eslintrc.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "extends": ["airbnb-base", ".eslintrc-todo"], - "env": { - "mocha": true, - "browser": true - }, - "rules": { - "comma-dangle": ["error", "only-multiline"], - "max-len": ["error", {"code":120, "comments": 120}], - "no-template-curly-in-string": "off", - "no-plusplus": "off", - "guard-for-in": "off", - "prefer-destructuring": "off", - "no-else-return": ["warn", {"allowElseIf": true}], - "globals": { - "window": "readonly" - }, - "import/extensions": [ "always", - { "js": "always" } - ], - "import/prefer-default-export": "off" - } -} diff --git a/.github/workflows/on-push.yml b/.github/workflows/on-push.yml index fe8b6c9..884f7dc 100644 --- a/.github/workflows/on-push.yml +++ b/.github/workflows/on-push.yml @@ -11,14 +11,9 @@ jobs: test: runs-on: ubuntu-latest steps: - - name: Start xvfb on Linux - if: matrix.os == 'ubuntu-latest' - run: | - export DISPLAY=:99.0 - Xvfb -ac :99 -screen 0 1280x1024x16 > /dev/null 2>&1 & - - uses: actions/checkout@v1 - - uses: actions/setup-node@v1 + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 with: - node-version: 12 - - run: yarn - - run: xvfb-run --auto-servernum yarn test + node-version: 20 + - run: npm ci + - run: xvfb-run --auto-servernum npm test diff --git a/.gitignore b/.gitignore index 6f954df..1d56386 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,5 @@ archive/ node_modules/ .nyc_output test-user-data-dir +test-results/ +test/e2e/.tmp-profile-*/ diff --git a/Dockerfile.test b/Dockerfile.test new file mode 100644 index 0000000..d2c9a84 --- /dev/null +++ b/Dockerfile.test @@ -0,0 +1,17 @@ +FROM mcr.microsoft.com/playwright:v1.58.0-noble + +RUN apt-get update && apt-get install -y --no-install-recommends \ + xvfb \ + x11-utils \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app +COPY package.json package-lock.json* ./ +RUN npm ci +COPY . . + +ENV DOCKER=1 +ENV DISPLAY=:99 +ENV PLAYWRIGHT_FORCE_TTY=0 + +CMD xvfb-run --auto-servernum --server-args="-screen 0 1280x720x24" npx playwright test test/e2e/ --reporter=list 2>&1 diff --git a/REFACTORING_PLAN.md b/REFACTORING_PLAN.md new file mode 100644 index 0000000..b72c1fa --- /dev/null +++ b/REFACTORING_PLAN.md @@ -0,0 +1,203 @@ +# Refactoring Plan — RobotFramework Recorder + +> Generated 2026-02-12. Based on full source analysis of all files in `src/`, `test/`, config, and assets. + +--- + +## 1. Code Quality Issues + +### 1.1 Dead Code +- **`src/popup.js:16-30`** — Commented-out Google Analytics block (`gaAccount`, `_gaq`). The `analytics()` function on line 32 is a no-op stub. Remove both entirely. +- **`src/popup.js:34`** — `analytics()` is called in 5 places but does nothing. Remove all call sites. +- **`src/content.js:8-17`** — Large commented-out `MutationObserver` block. Either implement or remove. +- **`src/background.js:8`** — `tab` is imported from constants but shadowed by the destructured `const [tab]` on line 72. The import is unused. +- **`src/background.js:7`** — `url` import is only used in the `info` operation (line 168) — consider inlining. +- **`src/background.js:14`** — `maxLength = 5000` is passed as the `length` parameter but `generateOutput` just uses it as a cap on list iteration. The name is misleading — it's max *actions*, not max *length*. +- **`src/background.js:4`** — `/* global instruction filename statusMessage url tab logo initializeTranslator */` lists globals that are now ES module imports. Remove the comment. +- **`src/popup.js:1`** — `/* global document chrome IntroTour t getCurrentLanguage setLanguage */` — `IntroTour` is loaded via ` diff --git a/src/actions-view.js b/src/actions-view.js index 5755788..d712f1e 100644 --- a/src/actions-view.js +++ b/src/actions-view.js @@ -1,12 +1,14 @@ -/* global chrome t getCurrentLanguage */ +/* global chrome */ import logger from './logger.js'; -import { filename } from './constants.js'; +import { t, getCurrentLanguage } from './translations.js'; import { initializeTranslator } from './translator/robot-translator.js'; +import { parseLine, getKeywordSpec, getCategoryColor } from './keyword-spec.js'; const storage = chrome.storage.local; let currentLanguage = 'en'; +let currentLibrary = 'Browser'; async function initLanguage() { try { currentLanguage = await getCurrentLanguage(); @@ -53,6 +55,110 @@ function copyToClipboard(text) { }); } +/** + * Create a structured action card for a single script line. + * Shows keyword with category color, named parameters, and controls. + */ +function createActionCard(text, idx, { onDelete } = {}) { + const parsed = parseLine(text); + const spec = parsed ? getKeywordSpec(parsed.keyword, currentLibrary) : null; + const catColor = parsed ? getCategoryColor(parsed.keyword, currentLibrary) : '#6B7280'; + + const card = document.createElement('div'); + card.className = 'action-card script-line-row'; + card.dataset.index = String(idx); + + // Line number + const indexSpan = document.createElement('span'); + indexSpan.className = 'script-line-index'; + indexSpan.textContent = String(idx + 1); + card.appendChild(indexSpan); + + // Main content area + const content = document.createElement('div'); + content.className = 'action-card-content'; + + // Keyword badge + const badge = document.createElement('span'); + badge.className = 'action-keyword-badge'; + badge.style.setProperty('--cat-color', catColor); + badge.textContent = parsed ? (spec?.icon ? `${spec.icon} ` : '') + parsed.keyword : text.trim(); + content.appendChild(badge); + + // Parameters + if (parsed && parsed.args.length > 0) { + const paramsContainer = document.createElement('div'); + paramsContainer.className = 'action-params'; + + parsed.args.forEach((arg, argIdx) => { + const paramWrapper = document.createElement('div'); + paramWrapper.className = 'action-param'; + + // Parameter label + const label = document.createElement('span'); + label.className = 'action-param-label'; + if (spec && spec.params[argIdx]) { + label.textContent = spec.params[argIdx].name; + } else { + label.textContent = `arg${argIdx + 1}`; + } + paramWrapper.appendChild(label); + + // Parameter value + const valueInput = document.createElement('input'); + valueInput.className = 'action-param-value'; + valueInput.value = arg; + valueInput.readOnly = true; + if (spec && spec.params[argIdx]?.placeholder) { + valueInput.placeholder = spec.params[argIdx].placeholder; + } + // Type hint styling + if (spec && spec.params[argIdx]) { + valueInput.dataset.paramType = spec.params[argIdx].type; + } + paramWrapper.appendChild(valueInput); + + paramsContainer.appendChild(paramWrapper); + }); + + content.appendChild(paramsContainer); + } + + // Raw line (collapsed, for copy) + const rawInput = document.createElement('input'); + rawInput.className = 'script-line-input'; + rawInput.type = 'hidden'; + rawInput.value = text; + content.appendChild(rawInput); + + card.appendChild(content); + + // Controls + const controls = document.createElement('div'); + controls.className = 'script-line-controls'; + + const copyBtn = document.createElement('button'); + copyBtn.className = 'av-icon-btn av-icon-copy'; + copyBtn.title = t('copyThisLine', currentLanguage); + copyBtn.addEventListener('click', () => { + copyToClipboard(text); + displayStatus('lineCopied'); + }); + + const delBtn = document.createElement('button'); + delBtn.className = 'av-icon-btn av-icon-delete'; + delBtn.title = t('deleteThisLine', currentLanguage); + delBtn.addEventListener('click', () => { + if (onDelete) onDelete(idx); + }); + + controls.appendChild(copyBtn); + controls.appendChild(delBtn); + card.appendChild(controls); + + return card; +} + // Render when we already have pre-generated lines (e.g. stored script) function renderActionsFromLines(lines) { const container = document.getElementById('actions-list'); @@ -64,48 +170,15 @@ function renderActionsFromLines(lines) { } lines.forEach((text, idx) => { - const row = document.createElement('div'); - row.className = 'script-line-row'; - row.dataset.index = String(idx); - - const indexSpan = document.createElement('span'); - indexSpan.className = 'script-line-index'; - indexSpan.textContent = String(idx + 1); - - const input = document.createElement('input'); - input.className = 'script-line-input'; - input.value = text; - input.readOnly = true; - - const controls = document.createElement('div'); - controls.className = 'script-line-controls'; - - const copyBtn = document.createElement('button'); - copyBtn.className = 'av-icon-btn av-icon-copy'; - copyBtn.setAttribute('aria-label', t('copyThisLine', currentLanguage)); - copyBtn.title = t('copyThisLine', currentLanguage); - copyBtn.addEventListener('click', () => { - copyToClipboard(text); - displayStatus('lineCopied'); + const card = createActionCard(text, idx, { + onDelete: async (i) => { + lines.splice(i, 1); + const newScript = lines.join('\n'); + await storage.set({ script: newScript }); + renderActionsFromLines(lines); + }, }); - - const exportBtn = document.createElement('button'); - exportBtn.className = 'av-icon-btn av-icon-export'; - exportBtn.setAttribute('aria-label', t('exportThisLine', currentLanguage)); - exportBtn.title = t('exportThisLine', currentLanguage); - exportBtn.addEventListener('click', () => { - downloadBlob(`${text}\n`, `line-${idx + 1}.robot`, 'text/plain'); - displayStatus('lineExported'); - }); - - controls.appendChild(copyBtn); - controls.appendChild(exportBtn); - - row.appendChild(indexSpan); - row.appendChild(input); - row.appendChild(controls); - - container.appendChild(row); + container.appendChild(card); }); } @@ -303,6 +376,7 @@ async function loadActions() { const verify = res.verify || false; const target = res.target || 'SeleniumLibrary'; const syntax = res.syntax || 'rpa'; + currentLibrary = target; // initialize translator const translator = initializeTranslator(target, syntax); // If stored script differs from translator output, prefer stored script. @@ -325,19 +399,17 @@ async function loadActions() { } } -// React to external storage changes so popup and other pages stay in sync -if (storage && storage.onChanged && typeof storage.onChanged.addListener === 'function') { - storage.onChanged.addListener((changes, area) => { - // only respond to local storage changes - if (area !== 'local') return; - const interesting = ['list', 'script', 'demo', 'verify', 'target', 'syntax']; - const keys = Object.keys(changes || {}); - if (keys.some(k => interesting.includes(k))) { - // reload to reflect current state - loadActions().catch(err => logger.error('Failed to reload actions after storage change', err)); - } - }); -} +// React to external storage changes so popup and other pages stay in sync. +// Use chrome.storage.onChanged (top-level) which provides the area parameter, +// rather than chrome.storage.local.onChanged which only passes changes. +chrome.storage.onChanged.addListener((changes, area) => { + if (area !== 'local') return; + const interesting = ['list', 'script', 'demo', 'verify', 'target', 'syntax']; + const keys = Object.keys(changes || {}); + if (keys.some(k => interesting.includes(k))) { + loadActions().catch(err => logger.error('Failed to reload actions after storage change', err)); + } +}); async function exportRobot() { try { @@ -367,14 +439,142 @@ async function clearScript() { } } +// --------------------------------------------------------------------------- +// Extract Keyword — select lines → wrap in a new keyword +// --------------------------------------------------------------------------- + +async function extractKeyword() { + const res = await storage.get({ script: '', target: 'Browser' }); + const script = res.script || ''; + if (!script.trim()) { + displayStatus('No script to extract from'); + return; + } + + const lines = script.split('\n'); + const actionLines = lines.filter(l => l.trim().length > 0); + if (actionLines.length === 0) { + displayStatus('No actions to extract'); + return; + } + + // Prompt for keyword name + const kwName = window.prompt( + 'Name for the new keyword:', + 'My Custom Keyword' + ); + if (!kwName) return; + + // Detect variables used (${...}) to make them arguments + const varPattern = /\$\{([^}]+)\}/g; + const varsUsed = new Set(); + for (const line of actionLines) { + let match; + while ((match = varPattern.exec(line)) !== null) { + varsUsed.add(match[1]); + } + } + + // Build keyword definition + const kwLines = [`${kwName}`]; + if (varsUsed.size > 0) { + const argLine = ' [Arguments] ' + + [...varsUsed].map(v => `\${${v}}`).join(' '); + kwLines.push(argLine); + } + kwLines.push( + ...actionLines.map(l => ' ' + l.replace(/^\s+/, '')) + ); + + // Build resource file content + const resourceContent = [ + '*** Keywords ***', + ...kwLines, + ].join('\n'); + + // Store the keyword definition + const existing = await storage.get({ keywords: [] }); + const keywords = existing.keywords || []; + keywords.push({ + name: kwName, + lines: kwLines, + args: [...varsUsed], + created: new Date().toISOString(), + }); + await storage.set({ keywords }); + + // Replace the script with a call to the new keyword + const callLine = varsUsed.size > 0 + ? ' ' + kwName + ' ' + + [...varsUsed].map(v => `\${${v}}`).join(' ') + : ' ' + kwName; + await storage.set({ script: callLine }); + + displayStatus(`Extracted keyword: ${kwName}`); + + // Offer download of the resource file + downloadBlob( + resourceContent, + `${kwName.replace(/\s+/g, '_').toLowerCase()}.resource`, + 'text/plain;charset=utf-8' + ); + + await loadActions(); +} + +// --------------------------------------------------------------------------- +// Export as .resource library file +// --------------------------------------------------------------------------- + +async function exportResource() { + const res = await storage.get({ + script: '', keywords: [], target: 'Browser', + }); + const script = res.script || ''; + const keywords = res.keywords || []; + const library = res.target || 'Browser'; + + const lines = [ + '*** Settings ***', + `Library ${library}`, + '', + '*** Keywords ***', + ]; + + // Add stored custom keywords + for (const kw of keywords) { + lines.push(...kw.lines); + lines.push(''); + } + + // If current script has content, add it as "Recorded Actions" + if (script.trim()) { + lines.push('Recorded Actions'); + const scriptLines = script.split('\n'); + for (const sl of scriptLines) { + if (sl.trim()) { + lines.push(' ' + sl.replace(/^\s+/, '')); + } + } + } + + const content = lines.join('\n'); + downloadBlob( + content, + 'keywords.resource', + 'text/plain;charset=utf-8' + ); + displayStatus('Exported as .resource library'); +} + function init() { // initialize language then wire UI text and handlers initLanguage().then(() => { // set document title and heading according to language try { document.title = t('pageTitle', currentLanguage) || document.title; - } catch (e) { - // ignore if document not available + } catch (err) { + console.warn('RF Recorder: could not set document title:', err); } const heading = document.getElementById('actions-heading'); if (heading) heading.textContent = t('actionsHeading', currentLanguage) || heading.textContent; @@ -396,8 +596,22 @@ function init() { }); document.getElementById('clear-script').addEventListener('click', () => clearScript()); + // Extract keyword & export resource + const extractBtn = document.getElementById('extract-keyword'); + if (extractBtn) { + extractBtn.textContent = t('extractKeyword', currentLanguage); + extractBtn.addEventListener('click', () => extractKeyword()); + } + const exportResBtn = document.getElementById('export-resource'); + if (exportResBtn) { + exportResBtn.textContent = t('exportResource', currentLanguage); + exportResBtn.addEventListener('click', () => exportResource()); + } + // Initial load loadActions(); + }).catch(err => { + console.error('RF Recorder actions-view init failed:', err); }); } diff --git a/src/background.js b/src/background.js index a750bea..d051e02 100644 --- a/src/background.js +++ b/src/background.js @@ -1,27 +1,70 @@ -/* global chrome URL Blob */ -/* global instruction filename statusMessage url tab logo initializeTranslator */ +/** + * Background service worker for RobotFramework Recorder. + * + * Architecture: + * - Message handlers are registered in a handler map for clean dispatch. + * - All mutable state is synced to chrome.storage after every mutation, + * so it survives service worker termination/restart. + * - Every handler returns a response object for consistency. + */ import { - url, logo, filename, statusMessage, instruction + logo, filename, statusMessage, instruction, DEFAULT_TARGET, DEFAULT_SYNTAX } from './constants.js'; import { initializeTranslator } from './translator/robot-translator.js'; import logger from './logger.js'; -const host = chrome; +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const INFO_URL = 'https://github.com/viadee/robotframework-recorder'; +const MAX_ACTIONS = 5000; +const storage = chrome.storage.local; + +// --------------------------------------------------------------------------- +// Mutable state — always kept in sync with chrome.storage via saveState() +// --------------------------------------------------------------------------- let list = []; let script; -const storage = host.storage.local; -const content = host.tabs; -const icon = host.action; -const maxLength = 5000; let recordTab = 0; let demo = false; let verify = false; -let target = 'SeleniumLibrary'; -let syntax = 'rpa'; +let target = DEFAULT_TARGET; +let syntax = DEFAULT_SYNTAX; + +// --------------------------------------------------------------------------- +// State persistence +// --------------------------------------------------------------------------- + +/** Restore mutable state from chrome.storage (called on service worker wake). */ +async function loadState() { + const saved = await storage.get({ + list: [], + recordTab: 0, + demo: false, + verify: false, + target: DEFAULT_TARGET, + syntax: DEFAULT_SYNTAX, + }); + list = saved.list; + recordTab = saved.recordTab; + demo = saved.demo; + verify = saved.verify; + target = saved.target; + syntax = saved.syntax; + logger.info('State loaded:', { list: list.length, recordTab, demo, verify, target, syntax }); +} + +/** Persist mutable state to chrome.storage. Call after every mutation. */ +async function saveState() { + await storage.set({ list, recordTab, demo, verify, target, syntax }); + logger.info('State saved'); +} +/** Set storage defaults for first install. */ async function setupStorageDefaults() { const defaults = { locators: ['for', 'name', 'id', 'title', 'href', 'class'], @@ -31,283 +74,323 @@ async function setupStorageDefaults() { verify: false, canSave: false, isBusy: false, - target: 'SeleniumLibrary' + target: DEFAULT_TARGET, + syntax: DEFAULT_SYNTAX, }; - const existing = await chrome.storage.local.get(Object.keys(defaults)); + const existing = await storage.get(Object.keys(defaults)); const toInit = {}; - for (const [key, value] of Object.entries(defaults)) { - if (existing[key] === undefined) { - toInit[key] = value; - } + if (existing[key] === undefined) toInit[key] = value; } if (Object.keys(toInit).length > 0) { - await chrome.storage.local.set(toInit); + await storage.set(toInit); logger.info('Storage initialized with defaults:', toInit); - } else { - logger.info('Storage already initialized'); } } -async function initState() { - const saved = await chrome.storage.local.get({ - list: [], - recordTab: 0, - demo: false, - verify: false - }); - Object.assign({ - list, recordTab, demo, verify - }, saved); - list = saved.list; - recordTab = saved.recordTab; - demo = saved.demo; - verify = saved.verify; - logger.info('State loaded:', { - list, recordTab, demo, verify - }); +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function handleError(error) { + const lastError = chrome.runtime.lastError; + const message = (lastError && lastError.message) || (error && error.message) || String(error); + console.error('RF Recorder error:', message, error); + storage.set({ message: statusMessage.failure, canSave: false }); } -async function saveState() { - await chrome.storage.local.set({ - list, recordTab, demo, verify +/** Send a message to a content script tab, returning a promise. */ +function contentSendMessage(tabId, msg) { + return new Promise((resolve, reject) => { + chrome.tabs.sendMessage(tabId, msg, (response) => { + if (chrome.runtime.lastError) reject(chrome.runtime.lastError); + else resolve(response); + }); }); - logger.info('State saved'); } -(async () => { - await setupStorageDefaults(); - await initState(); -})(); +/** Safely send a message to the content script, logging errors. */ +async function sendToContent(tabId, msg) { + try { + const response = await contentSendMessage(tabId, msg); + logger.info('Content script response:', response); + return response; + } catch (err) { + handleError(err); + return null; + } +} + +/** Get the active tab, falling back to sender tab. */ +async function resolveActiveTab(sender) { + const [activeTab] = await chrome.tabs.query({ active: true }); + return activeTab ?? sender?.tab ?? null; +} -async function selection(item) { - const prevItem = list[list.length - 1]; - const shouldReplace = item.trigger === 'change' && prevItem && prevItem.trigger === 'click'; - const timeGapOkay = !prevItem || Math.abs(item.time - prevItem.time) > 20; +/** Append an action item to the list with dedup logic. */ +async function appendAction(item) { + const prev = list[list.length - 1]; + const shouldReplace = item.trigger === 'change' && prev?.trigger === 'click'; + const timeGapOk = !prev || Math.abs(item.time - prev.time) > 20; if (shouldReplace) { list[list.length - 1] = item; - } else if (!prevItem || timeGapOkay || item.trigger !== 'click') { + } else if (!prev || timeGapOk || item.trigger !== 'click') { list.push(item); } await saveState(); } -// Using centralized logger imported above +// --------------------------------------------------------------------------- +// Message handlers — each receives { message, sender, tab, translator } +// and returns a response object. +// --------------------------------------------------------------------------- -function handleError(error) { - const lastError = host.runtime.lastError; - const message = (lastError && lastError.message) || (error && error.message) || String(error); - logger.debug('Chrome/API error:', message); - storage.set({ message: statusMessage.failure, canSave: false }); +async function handleRecord({ tab }) { + list = [{ + type: 'url', path: tab.url, time: 0, trigger: 'record', title: tab.title, + }]; + await saveState(); + + chrome.action.setIcon({ path: logo.record }); + await storage.set({ message: statusMessage.record, operation: 'record', canSave: false }); + + try { + await chrome.scripting.executeScript({ + target: { tabId: tab.id }, + files: ['src/content.js'], + }); + logger.info('content.js injected into', tab.url); + } catch (err) { + logger.error('Injection failed:', err); + } + + await sendToContent(tab.id, { operation: 'record' }); + return { ok: true }; } -function contentSendMessage(tabId, message) { - return new Promise((resolve, reject) => { - content.sendMessage(tabId, message, (response) => { - if (host.runtime && host.runtime.lastError) reject(host.runtime.lastError); - else resolve(response); +async function handlePause({ tab }) { + chrome.action.setIcon({ path: logo.pause }); + await sendToContent(tab.id, { operation: 'stop' }); + await storage.set({ operation: 'pause', canSave: false, isBusy: false }); + return { ok: true }; +} + +async function handleResume({ tab }) { + chrome.action.setIcon({ path: logo.record }); + await sendToContent(tab.id, { operation: 'record' }); + await storage.set({ message: statusMessage.record, operation: 'record', canSave: false }); + return { ok: true }; +} + +async function handleScan({ message, tab }) { + if (!tab) { + await storage.set({ + message: statusMessage.failedScan, operation: 'scan', canSave: false, isBusy: false, }); + return { ok: false, error: 'No active tab' }; + } + + list = [{ + type: 'url', path: tab.url, time: 0, trigger: 'scan', title: tab.title, + }]; + await saveState(); + + await storage.set({ + message: statusMessage.scan, operation: 'scan', canSave: false, isBusy: true, }); + + await sendToContent(tab.id, { operation: 'scan', locators: message.locators }); + return { ok: true }; } -host.runtime.onMessage.addListener(async (message, sender, sendResponse) => { - recordTab = sender.tab || message.recordTab || recordTab; - const [tab] = await content.query({ active: true }); - if (tab !== null && tab !== undefined) { - recordTab = tab; - } else if (sender.tab) { - recordTab = sender.tab; +async function handleStop({ tab, translator }) { + chrome.action.setIcon({ path: logo.stop }); + + script = translator.generateOutput(list, MAX_ACTIONS, demo, verify); + + if (script) { + await storage.set({ + message: statusMessage.succesfulRecord, script, operation: 'stop', canSave: true, + }); + } else { + await storage.set({ + message: statusMessage.failedRecord, operation: 'stop', canSave: false, + }); } + + await sendToContent(tab.id, { operation: 'stop' }); + return { ok: true, hasScript: !!script }; +} + +async function handleSave({ translator, sendResponse }) { try { - const items = await storage.get(['target', 'syntax']); - const translator = initializeTranslator(items.target, items.syntax); - let { operation } = message; - logger.debug(message); - - if (operation === 'record') { - list = []; - icon.setIcon({ path: logo[operation] }); - - list = [{ - type: 'url', path: recordTab.url, time: 0, trigger: 'record', title: recordTab.title - }]; - await saveState(); - storage.set({ message: statusMessage.record, operation: 'record', canSave: false }); - - try { - await chrome.scripting.executeScript({ - target: { tabId: tab.id }, - files: ['src/content.js'] - }); - logger.info('content.js injected into', tab.url); - } catch (err) { - logger.error('Injection failed:', err); - } + const file = translator.generateFile(list, MAX_ACTIONS, demo, verify); + const blob = new Blob([file], { type: 'text/plain;charset=utf-8' }); + const reader = new FileReader(); + reader.onload = () => { + chrome.downloads.download({ url: reader.result, filename }); + sendResponse({ ok: true }); + }; + reader.readAsDataURL(blob); + } catch (err) { + sendResponse({ ok: false, error: err.message }); + } + // Return null to signal "don't call sendResponse from dispatcher" + return null; +} - // FIXME: just passing handleError does not work. Need some advanced solution. - try { - const response = await contentSendMessage(recordTab.id, { operation }); - logger.info('Response from the content script:', response); - } catch (err) { - handleError(err); - } - } else if (operation === 'pause') { - icon.setIcon({ path: logo.pause }); - - try { - const response = await contentSendMessage(recordTab.id, { operation: 'stop' }); - logger.info('Response from the content script:', response); - } catch (err) { - handleError(err); - } +async function handleSettings({ message }) { + ({ demo, verify, target, syntax } = message); + await saveState(); + await storage.set({ demo, verify, target, syntax }); + return { ok: true }; +} - storage.set({ operation: 'pause', canSave: false, isBusy: false }); - } else if (operation === 'resume') { - operation = 'record'; +async function handleLoad({ sender }) { + const state = await storage.get({ operation: 'stop', locators: [] }); + await sendToContent(sender.tab.id, { + operation: state.operation, locators: state.locators, + }); + return { ok: true }; +} - icon.setIcon({ path: logo.record }); +async function handleInfo() { + chrome.tabs.create({ url: INFO_URL }); + return { ok: true }; +} - try { - const response = await contentSendMessage(recordTab.id, { operation }); - logger.info('Response from the content script:', response); - } catch (err) { - handleError(err); - } +async function handleAppend({ message, translator }) { + await appendAction(message.script); + // Generate live script preview so the side panel shows actions in real-time + script = translator.generateOutput(list, MAX_ACTIONS, demo, verify); + await storage.set({ script }); + chrome.action.setIcon({ path: logo.action }); + setTimeout(() => chrome.action.setIcon({ path: logo.record }), 1000); + return { ok: true }; +} - storage.set({ message: statusMessage.record, operation, canSave: false }); - } else if (operation === 'scan') { - if (recordTab) { - list = [{ - type: 'url', path: recordTab.url, time: 0, trigger: 'scan', title: recordTab.title - }]; - await saveState(); - // TODO: message.locators should be set here - await storage.set({ - message: statusMessage.scan, - operation: 'scan', - canSave: false, - isBusy: true - }); - - try { - const response = await contentSendMessage(recordTab.id, { operation, locators: message.locators }); - logger.info('Response from the content script:', response); - } catch (error) { - handleError(error); - } - } else { - await storage.set({ - message: statusMessage.failedScan, operation: 'scan', canSave: false, isBusy: false - }); - } - } else if (operation === 'stop') { - icon.setIcon({ path: logo[operation] }); - - script = translator.generateOutput(list, maxLength, demo, verify); - if (script) { - await storage.set({ - message: statusMessage.succesfulRecord, script, operation: 'stop', canSave: true - }); - - try { - const response = await contentSendMessage(recordTab.id, { operation: 'stop' }); - logger.info('Response from the content script:', response); - } catch (error) { - handleError(error); - } - } else { - await storage.set({ message: statusMessage.failedRecord, operation, canSave: false }); - try { - const response = await contentSendMessage(recordTab.id, { operation: 'stop' }); - logger.info('Response from the content script:', response); - } catch (error) { - handleError(error); - } - } - } else if (operation === 'save') { - (async () => { - try { - const file = translator.generateFile(list, maxLength, demo, verify); - logger.debug(file); - const blob = new Blob([file], { type: 'text/plain;charset=utf-8' }); - const reader = new FileReader(); - reader.onload = () => { - chrome.downloads.download({ url: reader.result, filename }); - sendResponse({ ok: true }); - }; - reader.readAsDataURL(blob); - } catch (err) { - sendResponse({ ok: false, error: err.message }); - } - })(); - return true; - } else if (operation === 'settings') { - ({ - demo, verify, target, syntax - } = message); - storage.set({ - demo, verify, target, syntax - }); - } else if (operation === 'load') { - // TODO: this is what causes scan to run after page is refreshed - // TODO: ensure state.locators has a value - const state = await storage.get({ - operation: 'stop', - locators: [] - }); - - try { - const response = await contentSendMessage(sender.tab.id, - { operation: state.operation, locators: state.locators }); - logger.info('Response from the content script:', response); - } catch (error) { - handleError(error); +async function handleAction({ message, translator }) { + chrome.action.setIcon({ path: logo.stop }); + list = list.concat(message.scripts); + await saveState(); + + script = translator.generateOutput(list, MAX_ACTIONS, demo, verify); + await storage.set({ + message: statusMessage.idle, script, operation: 'stop', isBusy: false, canSave: true, + }); + return { ok: true }; +} + +async function handleClearScript() { + list = []; + await saveState(); + await storage.set({ message: 'Cleared', canSave: false }); + await storage.remove('script'); + return { ok: true }; +} + +async function handleXpathValidate({ message, tab }) { + await sendToContent(tab.id, { operation: 'xpath-validate', xpath: message.xpath }); + return { ok: true }; +} + +async function handleDisplay({ message }) { + await storage.set({ message: message.message }); + return { ok: true }; +} + +async function handleOpenActionsView() { + try { + chrome.tabs.create({ url: chrome.runtime.getURL('src/actions-view.html') }); + } catch (err) { + logger.warn('Could not open actions view:', err); + } + return { ok: true }; +} + +// --------------------------------------------------------------------------- +// Handler registry +// --------------------------------------------------------------------------- + +const handlers = { + record: handleRecord, + pause: handlePause, + resume: handleResume, + scan: handleScan, + stop: handleStop, + save: handleSave, + settings: handleSettings, + load: handleLoad, + info: handleInfo, + append: handleAppend, + action: handleAction, + 'clear-script': handleClearScript, + 'xpath-validate': handleXpathValidate, + display: handleDisplay, + 'open-actions-view': handleOpenActionsView, +}; + +// --------------------------------------------------------------------------- +// Message dispatcher +// --------------------------------------------------------------------------- + +chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { + (async () => { + try { + // Lazy init — only runs once, on first message + await ensureInitialized(); + + // Resolve active tab — but don't overwrite recordTab with extension pages + const tab = await resolveActiveTab(sender); + const isExtensionTab = tab?.url?.startsWith('chrome-extension://'); + if (tab && !isExtensionTab) { + recordTab = tab; + } else if (sender.tab && !sender.tab.url?.startsWith('chrome-extension://')) { + recordTab = sender.tab; } - } else if (operation === 'info') { - host.tabs.create({ url }); - } else if (operation === 'append') { - selection(message.script); - icon.setIcon({ path: logo.action }); - setTimeout(() => { icon.setIcon({ path: logo.record }); }, 1000); - } else if (operation === 'action') { - icon.setIcon({ path: logo.stop }); - list = list.concat(message.scripts); - await saveState(); - script = translator.generateOutput(list, maxLength, demo, verify); - - await storage.set({ - message: statusMessage.idle, script, operation: 'stop', isBusy: false, canSave: true - }); - } else if (operation === 'clear-script') { - list = []; - await saveState(); - await storage.set({ message: 'Cleared', canSave: false }); - await storage.remove('script'); - } else if (operation === 'xpath-validate') { - try { - const response = await contentSendMessage(recordTab.id, { operation: 'xpath-validate', xpath: message.xpath }); - logger.info('Response from the content script:', response); - } catch (error) { - handleError(error); + + // Initialize translator with current settings + const items = await storage.get({ target: DEFAULT_TARGET, syntax: DEFAULT_SYNTAX }); + const translator = initializeTranslator(items.target, items.syntax); + + const { operation } = message; + logger.debug('Received:', operation, message); + + const handler = handlers[operation]; + if (!handler) { + logger.warn('Unknown operation:', operation); + sendResponse({ ok: false, error: `Unknown operation: ${operation}` }); + return; } - } else if (operation === 'display') { - await storage.set({ message: message.message }); - } else if (operation === 'open-actions-view') { - // Open a dedicated Actions Viewer page in a new tab (uses extension page context) - try { - host.tabs.create({ url: chrome.runtime.getURL('src/actions-view.html') }); - } catch (err) { - logger.warn('Could not open actions view:', err); + + const result = await handler({ message, sender, tab: recordTab, translator, sendResponse }); + + // null result means handler called sendResponse itself (e.g. save) + if (result !== null) { + sendResponse(result); } + } catch (error) { + logger.error('Message handler error:', error); + sendResponse({ ok: false, error: error.message }); } - // https://github.com/mozilla/webextension-polyfill/issues/130 lets chrome now that our callback succeeded - sendResponse({}); - } catch (error) { - logger.error('Error reading from storage:', error); - sendResponse({}); - } + })(); + + // Keep the message channel open for async response return true; }); + +// --------------------------------------------------------------------------- +// Lazy initialization — only runs when first message arrives +// --------------------------------------------------------------------------- + +let _initialized = false; + +async function ensureInitialized() { + if (_initialized) return; + _initialized = true; + await setupStorageDefaults(); + await loadState(); +} diff --git a/src/constants.js b/src/constants.js index c193fdf..b326b31 100644 --- a/src/constants.js +++ b/src/constants.js @@ -1,3 +1,7 @@ +/** + * Shared constants for the RobotFramework Recorder extension. + */ + export const url = 'https://github.com/viadee/robotframework-recorder'; export const tab = { active: true, currentWindow: true }; @@ -9,14 +13,22 @@ export const logo = { action: '/assets/mark-128.png', pause: '/assets/icon-pause.png' }; -// This does not seem to propagate correctly to background.js + export const filename = 'robot_script.robot'; +/** Default target library — unified across popup and background. */ +export const DEFAULT_TARGET = 'Browser'; + +/** Default syntax mode. */ +export const DEFAULT_SYNTAX = 'rpa'; + export const statusMessage = { stop: 'Stopped', record: 'Recording action...', succesfulRecord: 'Recorded script', + failedRecord: 'Recording failed. No actions were captured.', scan: 'Scanning html document...', + failedScan: 'Scan failed. No active tab found.', failure: 'Operation failed. Please try refreshing the web page.', idle: 'Idle', }; diff --git a/src/content.js b/src/content.js index 9569996..0f2b398 100644 --- a/src/content.js +++ b/src/content.js @@ -28,11 +28,11 @@ function handleByChange(type) { const debug = false; const logger = debug ? { debug: (data) => { - /* eslint-disable-next-line no-console */ + console.debug(data); }, error: (data) => { - /* eslint-disable-next-line no-console */ + console.error(data); } } : { @@ -67,7 +67,7 @@ function xpathValidation(xpath) { xpathResult = document.evaluate(xpath, document, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE); } catch (error) { host.runtime.sendMessage({ operation: 'display', message: error.toString() }); - logger.debug(error); + console.error('RF Recorder XPath error:', error); } if (!xpathResult) return; host.runtime.sendMessage({ operation: 'display', message: `XPath is valid, matches: ${xpathResult.snapshotLength}` }); diff --git a/src/context-menu.js b/src/context-menu.js new file mode 100644 index 0000000..46a967a --- /dev/null +++ b/src/context-menu.js @@ -0,0 +1,517 @@ +/** + * context-menu.js — Right-click context menu for quick actions. + * + * Adds context menu items for: + * - Copy best selector + * - Insert Click / Fill Text / assertions / waits + * - Control structures (IF, FOR, WHILE, TRY) + */ + +import logger from './logger.js'; + +const storage = chrome.storage.local; + +// --------------------------------------------------------------------------- +// Menu structure +// --------------------------------------------------------------------------- + +const MENU_ITEMS = [ + { id: 'rfr-separator-top', type: 'separator' }, + { + id: 'rfr-copy-selector', + title: '📋 Copy Best Selector', + contexts: ['all'], + }, + { id: 'rfr-separator-actions', type: 'separator' }, + { + id: 'rfr-add-click', + title: '👆 Add Click', + contexts: ['all'], + }, + { + id: 'rfr-add-fill', + title: '✏️ Add Fill Text...', + contexts: ['editable'], + }, + { + id: 'rfr-add-hover', + title: '🔍 Add Hover', + contexts: ['all'], + }, + // Assertions submenu + { + id: 'rfr-assertions', + title: '✅ Add Assertion', + contexts: ['all'], + }, + { + id: 'rfr-assert-visible', + title: 'Element Should Be Visible', + parentId: 'rfr-assertions', + contexts: ['all'], + }, + { + id: 'rfr-assert-text', + title: 'Assert Text Equals...', + parentId: 'rfr-assertions', + contexts: ['all'], + }, + { + id: 'rfr-assert-count', + title: 'Assert Element Count == 1', + parentId: 'rfr-assertions', + contexts: ['all'], + }, + { + id: 'rfr-assert-contains', + title: 'Page Contains Element', + parentId: 'rfr-assertions', + contexts: ['all'], + }, + // Waits submenu + { + id: 'rfr-waits', + title: '⏳ Add Wait', + contexts: ['all'], + }, + { + id: 'rfr-wait-visible', + title: 'Wait Until Visible', + parentId: 'rfr-waits', + contexts: ['all'], + }, + { + id: 'rfr-wait-hidden', + title: 'Wait Until Hidden', + parentId: 'rfr-waits', + contexts: ['all'], + }, + { + id: 'rfr-wait-attached', + title: 'Wait Until Attached', + parentId: 'rfr-waits', + contexts: ['all'], + }, + // Control structures submenu + { + id: 'rfr-control', + title: '🔀 Control Structure', + contexts: ['all'], + }, + { + id: 'rfr-control-if', + title: 'IF / ELSE IF / ELSE / END', + parentId: 'rfr-control', + contexts: ['all'], + }, + { + id: 'rfr-control-for', + title: 'FOR / END', + parentId: 'rfr-control', + contexts: ['all'], + }, + { + id: 'rfr-control-while', + title: 'WHILE / END', + parentId: 'rfr-control', + contexts: ['all'], + }, + { + id: 'rfr-control-try', + title: 'TRY / EXCEPT / END', + parentId: 'rfr-control', + contexts: ['all'], + }, +]; + +// --------------------------------------------------------------------------- +// Create menus +// --------------------------------------------------------------------------- + +export function createContextMenus() { + // Remove any existing menus first + chrome.contextMenus.removeAll(() => { + for (const item of MENU_ITEMS) { + const opts = { + id: item.id, + contexts: item.contexts || ['all'], + }; + if (item.type === 'separator') { + opts.type = 'separator'; + } else { + opts.title = item.title; + } + if (item.parentId) opts.parentId = item.parentId; + chrome.contextMenus.create(opts); + } + logger.info('Context menus created'); + }); +} + +// --------------------------------------------------------------------------- +// Get selector from clicked element (runs in content script context) +// --------------------------------------------------------------------------- + +async function getSelectorFromTab(tabId, frameId) { + try { + const results = await chrome.scripting.executeScript({ + target: { tabId, frameIds: frameId ? [frameId] : undefined }, + func: () => { + // This runs in the page context + const el = document.activeElement + || document.querySelector(':hover'); + if (!el || el === document.body) return null; + + // Try ID first + if (el.id) return `//*[@id="${el.id}"]`; + + // Try name + if (el.name) { + return `//*[@name="${el.name}"]`; + } + + // Try data-testid + if (el.dataset.testid) { + return `//*[@data-testid="${el.dataset.testid}"]`; + } + + // Build XPath from tag + text or attributes + const tag = el.tagName.toLowerCase(); + const text = el.textContent?.trim(); + if (text && text.length < 50 && text.length > 0) { + return `//${tag}[contains(text(),"${ + text.replace(/"/g, "'") + }")]`; + } + + // Fallback: class-based + if (el.className && typeof el.className === 'string') { + const cls = el.className.split(/\s+/)[0]; + if (cls) return `//${tag}[@class="${cls}"]`; + } + + return null; + }, + }); + return results?.[0]?.result || null; + } catch (err) { + logger.warn('Could not get selector:', err); + return null; + } +} + +/** + * Get the text content of the right-clicked element. + */ +async function getElementText(tabId, frameId) { + try { + const results = await chrome.scripting.executeScript({ + target: { tabId, frameIds: frameId ? [frameId] : undefined }, + func: () => { + const el = document.activeElement + || document.querySelector(':hover'); + if (!el) return ''; + return el.textContent?.trim()?.substring(0, 200) || ''; + }, + }); + return results?.[0]?.result || ''; + } catch (err) { + logger.warn('Could not get element text:', err); + return ''; + } +} + +// --------------------------------------------------------------------------- +// Append a script line to storage +// --------------------------------------------------------------------------- + +async function appendScriptLine(line) { + const data = await storage.get({ script: '' }); + const existing = data.script || ''; + const newScript = existing + ? existing + '\n' + line + : line; + await storage.set({ + script: newScript, + canSave: true, + operation: 'stop', + }); + logger.info('Appended script line:', line); +} + +async function appendMultipleLines(lines) { + const data = await storage.get({ script: '' }); + const existing = data.script || ''; + const newScript = existing + ? existing + '\n' + lines.join('\n') + : lines.join('\n'); + await storage.set({ + script: newScript, + canSave: true, + operation: 'stop', + }); +} + +// --------------------------------------------------------------------------- +// Build RF lines based on current library +// --------------------------------------------------------------------------- + +async function getLibrary() { + const data = await storage.get({ target: 'Browser' }); + return data.target; +} + +function buildActionLine(library, keyword, args) { + const parts = [keyword, ...args].filter(Boolean); + return parts.join(' '); +} + +// --------------------------------------------------------------------------- +// Handle menu clicks +// --------------------------------------------------------------------------- + +export function handleContextMenuClick(info, tab) { + const menuId = info.menuItemId; + const tabId = tab?.id; + const frameId = info.frameId; + + if (!tabId) { + logger.warn('No tab for context menu action'); + return; + } + + (async () => { + const selector = await getSelectorFromTab(tabId, frameId); + const library = await getLibrary(); + + if (!selector && !menuId.startsWith('rfr-control')) { + logger.warn('Could not determine selector for element'); + await storage.set({ + message: 'Could not determine selector for element', + }); + return; + } + + switch (menuId) { + case 'rfr-copy-selector': + // Copy selector to clipboard via content script + await chrome.scripting.executeScript({ + target: { tabId }, + func: (sel) => navigator.clipboard.writeText(sel), + args: [selector], + }); + await storage.set({ message: `Copied: ${selector}` }); + break; + + case 'rfr-add-click': + if (library === 'Browser') { + await appendScriptLine( + buildActionLine(library, 'Click', [selector]) + ); + } else { + await appendScriptLine( + buildActionLine(library, 'Click Element', [selector]) + ); + } + await storage.set({ message: `Added Click on ${selector}` }); + break; + + case 'rfr-add-fill': { + // Prompt for text value via content script + const value = await promptInTab(tabId, 'Enter text value:'); + if (value === null) return; + if (library === 'Browser') { + await appendScriptLine( + buildActionLine(library, 'Fill Text', [selector, value]) + ); + } else { + await appendScriptLine( + buildActionLine(library, 'Input Text', [selector, value]) + ); + } + await storage.set({ + message: `Added Fill Text on ${selector}`, + }); + break; + } + + case 'rfr-add-hover': + if (library === 'Browser') { + await appendScriptLine( + buildActionLine(library, 'Hover', [selector]) + ); + } else { + await appendScriptLine( + buildActionLine(library, 'Mouse Over', [selector]) + ); + } + break; + + // Assertions + case 'rfr-assert-visible': + if (library === 'Browser') { + await appendScriptLine(buildActionLine( + library, 'Wait For Elements State', + [selector, 'visible'] + )); + } else { + await appendScriptLine(buildActionLine( + library, 'Element Should Be Visible', [selector] + )); + } + await storage.set({ message: 'Added visibility assertion' }); + break; + + case 'rfr-assert-text': { + const elText = await getElementText(tabId, frameId); + if (library === 'Browser') { + await appendScriptLine(buildActionLine( + library, 'Get Text', [selector, '==', elText] + )); + } else { + await appendScriptLine(buildActionLine( + library, 'Element Text Should Be', [selector, elText] + )); + } + await storage.set({ message: 'Added text assertion' }); + break; + } + + case 'rfr-assert-count': + if (library === 'Browser') { + await appendScriptLine(buildActionLine( + library, 'Get Element Count', [selector, '==', '1'] + )); + } else { + await appendScriptLine(buildActionLine( + library, 'Page Should Contain Element', [selector] + )); + } + await storage.set({ message: 'Added element count assertion' }); + break; + + case 'rfr-assert-contains': + if (library === 'Browser') { + await appendScriptLine(buildActionLine( + library, 'Get Element Count', + [selector, '>=', '1'] + )); + } else { + await appendScriptLine(buildActionLine( + library, 'Page Should Contain Element', [selector] + )); + } + await storage.set({ message: 'Added contains assertion' }); + break; + + // Waits + case 'rfr-wait-visible': + if (library === 'Browser') { + await appendScriptLine(buildActionLine( + library, 'Wait For Elements State', + [selector, 'visible', '10s'] + )); + } else { + await appendScriptLine(buildActionLine( + library, 'Wait Until Element Is Visible', + [selector, '10s'] + )); + } + await storage.set({ message: 'Added wait for visible' }); + break; + + case 'rfr-wait-hidden': + if (library === 'Browser') { + await appendScriptLine(buildActionLine( + library, 'Wait For Elements State', + [selector, 'hidden', '10s'] + )); + } else { + await appendScriptLine(buildActionLine( + library, 'Wait Until Element Is Not Visible', + [selector, '10s'] + )); + } + break; + + case 'rfr-wait-attached': + if (library === 'Browser') { + await appendScriptLine(buildActionLine( + library, 'Wait For Elements State', + [selector, 'attached', '10s'] + )); + } else { + await appendScriptLine(buildActionLine( + library, 'Wait Until Page Contains Element', + [selector, '10s'] + )); + } + break; + + // Control structures + case 'rfr-control-if': + await appendMultipleLines([ + ' IF ${condition}', + ' Log condition is true', + ' ELSE IF ${other_condition}', + ' Log other condition', + ' ELSE', + ' Log fallback', + ' END', + ]); + await storage.set({ message: 'Added IF/ELSE structure' }); + break; + + case 'rfr-control-for': + await appendMultipleLines([ + ' FOR ${item} IN @{items}', + ' Log ${item}', + ' END', + ]); + await storage.set({ message: 'Added FOR loop' }); + break; + + case 'rfr-control-while': + await appendMultipleLines([ + ' WHILE ${condition} limit=100', + ' Log iteration', + ' END', + ]); + await storage.set({ message: 'Added WHILE loop' }); + break; + + case 'rfr-control-try': + await appendMultipleLines([ + ' TRY', + ' Log try block', + ' EXCEPT AS ${error}', + ' Log Error: ${error}', + ' FINALLY', + ' Log cleanup', + ' END', + ]); + await storage.set({ message: 'Added TRY/EXCEPT structure' }); + break; + + default: + logger.debug('Unknown context menu id:', menuId); + } + })(); +} + +/** + * Prompt the user for input via the content script. + */ +async function promptInTab(tabId, message) { + try { + const results = await chrome.scripting.executeScript({ + target: { tabId }, + func: (msg) => window.prompt(msg), + args: [message], + }); + return results?.[0]?.result ?? null; + } catch (err) { + logger.warn('Prompt failed:', err); + return null; + } +} diff --git a/src/intro.js b/src/intro.js index 7dca003..c26fdf4 100644 --- a/src/intro.js +++ b/src/intro.js @@ -1,11 +1,9 @@ -/* global document */ - /** * Modern lightweight intro/tutorial system * Replaces ChardinJS with a minimal, modern implementation */ -class IntroTour { +export class IntroTour { constructor() { this.isActive = false; this.currentStep = 0; @@ -70,8 +68,6 @@ class IntroTour { const step = this.steps[index]; const rect = step.element.getBoundingClientRect(); - // Update overlay - // build box-shadow in parts to avoid long lines (ESLint max-len) const outerShadow = '0 0 0 9999px rgba(0, 0, 0, 0.7)'; const innerShadow = 'inset 0 0 0 1px rgba(0, 192, 181, 0.3)'; this.overlay.style.boxShadow = `${outerShadow}, ${innerShadow}`; @@ -81,11 +77,9 @@ class IntroTour { this.overlay.style.height = `${rect.height}px`; this.overlay.style.borderRadius = '4px'; - // Update tooltip this._positionTooltip(step, rect); this.tooltip.querySelector('.intro-text').textContent = step.intro; - // Update button text const nextBtn = this.tooltip.querySelector('.intro-next'); nextBtn.textContent = index === this.steps.length - 1 ? '✓ Done' : 'Next ›'; } @@ -95,8 +89,7 @@ class IntroTour { */ _positionTooltip(step, rect) { const padding = 12; - let top; let - left; + let top; let left; const tooltipWidth = 280; const tooltipHeight = 100; @@ -138,7 +131,6 @@ class IntroTour { * Create UI elements (overlay and tooltip) */ _createUI() { - // Overlay this.overlay = document.createElement('div'); this.overlay.className = 'intro-overlay'; this.overlay.style.cssText = ` @@ -150,7 +142,6 @@ class IntroTour { `; document.body.appendChild(this.overlay); - // Tooltip this.tooltip = document.createElement('div'); this.tooltip.className = 'intro-tooltip'; this.tooltip.style.cssText = ` @@ -167,7 +158,6 @@ class IntroTour { border-left: 4px solid #00c0b5; `; - // Build tooltip content programmatically to satisfy max-len rule const introText = document.createElement('div'); introText.className = 'intro-text'; introText.style.cssText = 'margin-bottom: 12px; color: #333;'; @@ -178,7 +168,6 @@ class IntroTour { const skipBtn = document.createElement('button'); skipBtn.className = 'intro-skip'; skipBtn.textContent = 'Skip'; - // set styles individually to avoid long lines skipBtn.style.padding = '4px 12px'; skipBtn.style.border = 'none'; skipBtn.style.background = '#f0f0f0'; @@ -206,7 +195,6 @@ class IntroTour { document.body.appendChild(this.tooltip); - // Event listeners this.overlay.addEventListener('click', () => this.stop()); this.tooltip.querySelector('.intro-skip').addEventListener('click', () => this.stop()); this.tooltip.querySelector('.intro-next').addEventListener('click', () => { @@ -218,7 +206,6 @@ class IntroTour { } }); - // Keyboard support this._keyHandler = (e) => { if (!this.isActive) return; if (e.key === 'Escape') this.stop(); @@ -255,8 +242,3 @@ class IntroTour { } } } - -// Export for use -if (typeof exports !== 'undefined') { - exports.IntroTour = IntroTour; -} diff --git a/src/keyword-spec.js b/src/keyword-spec.js new file mode 100644 index 0000000..1b39244 --- /dev/null +++ b/src/keyword-spec.js @@ -0,0 +1,362 @@ +/** + * keyword-spec.js — Keyword metadata for RF Browser and SeleniumLibrary. + * + * Maps keyword names to their parameter definitions, enabling structured + * editing with named parameter fields in the actions view. + */ + +const BROWSER_KEYWORDS = { + 'New Page': { + params: [ + { name: 'url', type: 'url', placeholder: 'https://example.com' }, + { name: 'browser', type: 'select', options: ['chromium', 'firefox', 'webkit'], optional: true }, + ], + category: 'navigation', + icon: '🌐', + }, + 'Go To': { + params: [{ name: 'url', type: 'url', placeholder: 'https://...' }], + category: 'navigation', + icon: '🌐', + }, + 'Click': { + params: [ + { name: 'selector', type: 'locator', placeholder: '//button[@id="..."]' }, + ], + category: 'interaction', + icon: '👆', + }, + 'Fill Text': { + params: [ + { name: 'selector', type: 'locator', placeholder: '//input[@id="..."]' }, + { name: 'text', type: 'text', placeholder: 'value' }, + ], + category: 'interaction', + icon: '✏️', + }, + 'Type Text': { + params: [ + { name: 'selector', type: 'locator', placeholder: '//input[@id="..."]' }, + { name: 'text', type: 'text', placeholder: 'value' }, + { name: 'delay', type: 'text', placeholder: '50ms', optional: true }, + ], + category: 'interaction', + icon: '⌨️', + }, + 'Check Checkbox': { + params: [{ name: 'selector', type: 'locator' }], + category: 'interaction', + icon: '☑️', + }, + 'Uncheck Checkbox': { + params: [{ name: 'selector', type: 'locator' }], + category: 'interaction', + icon: '⬜', + }, + 'Select Options By': { + params: [ + { name: 'selector', type: 'locator' }, + { name: 'attribute', type: 'select', options: ['value', 'label', 'text', 'index'] }, + { name: 'values', type: 'text', placeholder: 'option value' }, + ], + category: 'interaction', + icon: '📋', + }, + 'Hover': { + params: [{ name: 'selector', type: 'locator' }], + category: 'interaction', + icon: '🔍', + }, + 'Focus': { + params: [{ name: 'selector', type: 'locator' }], + category: 'interaction', + icon: '🎯', + }, + 'Press Keys': { + params: [ + { name: 'selector', type: 'locator' }, + { name: 'keys', type: 'text', placeholder: 'Enter, Tab, ...' }, + ], + category: 'interaction', + icon: '⌨️', + }, + 'Upload File By Selector': { + params: [ + { name: 'selector', type: 'locator' }, + { name: 'path', type: 'text', placeholder: '/path/to/file' }, + ], + category: 'interaction', + icon: '📁', + }, + 'Wait For Elements State': { + params: [ + { name: 'selector', type: 'locator' }, + { + name: 'state', type: 'select', optional: true, + options: ['visible', 'hidden', 'attached', 'detached', 'stable'], + }, + { name: 'timeout', type: 'text', placeholder: '10s', optional: true }, + ], + category: 'wait', + icon: '⏳', + }, + 'Wait For Condition': { + params: [ + { name: 'condition', type: 'text', placeholder: 'element.visible' }, + { name: 'timeout', type: 'text', placeholder: '10s', optional: true }, + ], + category: 'wait', + icon: '⏳', + }, + 'Get Text': { + params: [ + { name: 'selector', type: 'locator' }, + { + name: 'assertion_operator', type: 'select', optional: true, + options: ['==', '!=', 'contains', 'matches', 'starts', 'ends'], + }, + { name: 'assertion_expected', type: 'text', optional: true }, + ], + category: 'assertion', + icon: '📖', + }, + 'Get Element Count': { + params: [ + { name: 'selector', type: 'locator' }, + { name: 'assertion_operator', type: 'select', options: ['==', '!=', '>', '<', '>=', '<='], optional: true }, + { name: 'assertion_expected', type: 'text', optional: true }, + ], + category: 'assertion', + icon: '🔢', + }, + 'Get Url': { + params: [ + { name: 'assertion_operator', type: 'select', options: ['==', '!=', 'contains', 'matches'], optional: true }, + { name: 'assertion_expected', type: 'text', optional: true }, + ], + category: 'assertion', + icon: '🔗', + }, + 'Get Title': { + params: [ + { name: 'assertion_operator', type: 'select', options: ['==', '!=', 'contains'], optional: true }, + { name: 'assertion_expected', type: 'text', optional: true }, + ], + category: 'assertion', + icon: '📄', + }, + 'Take Screenshot': { + params: [ + { name: 'filename', type: 'text', placeholder: 'screenshot.png', optional: true }, + ], + category: 'utility', + icon: '📸', + }, + 'Sleep': { + params: [{ name: 'duration', type: 'text', placeholder: '3s' }], + category: 'utility', + icon: '💤', + }, + 'Close Browser': { + params: [], + category: 'navigation', + icon: '❌', + }, + 'Close Page': { + params: [], + category: 'navigation', + icon: '❌', + }, +}; + +const SELENIUM_KEYWORDS = { + 'Open Browser': { + params: [ + { name: 'url', type: 'url', placeholder: 'https://example.com' }, + { name: 'browser', type: 'select', options: ['chrome', 'firefox', 'edge', 'safari'], optional: true }, + ], + category: 'navigation', + icon: '🌐', + }, + 'Go To': { + params: [{ name: 'url', type: 'url', placeholder: 'https://...' }], + category: 'navigation', + icon: '🌐', + }, + 'Click Element': { + params: [{ name: 'locator', type: 'locator' }], + category: 'interaction', + icon: '👆', + }, + 'Input Text': { + params: [ + { name: 'locator', type: 'locator' }, + { name: 'text', type: 'text', placeholder: 'value' }, + ], + category: 'interaction', + icon: '✏️', + }, + 'Clear Element Text': { + params: [{ name: 'locator', type: 'locator' }], + category: 'interaction', + icon: '🧹', + }, + 'Select From List By Value': { + params: [ + { name: 'locator', type: 'locator' }, + { name: 'values', type: 'text' }, + ], + category: 'interaction', + icon: '📋', + }, + 'Select From List By Label': { + params: [ + { name: 'locator', type: 'locator' }, + { name: 'labels', type: 'text' }, + ], + category: 'interaction', + icon: '📋', + }, + 'Select Checkbox': { + params: [{ name: 'locator', type: 'locator' }], + category: 'interaction', + icon: '☑️', + }, + 'Unselect Checkbox': { + params: [{ name: 'locator', type: 'locator' }], + category: 'interaction', + icon: '⬜', + }, + 'Mouse Over': { + params: [{ name: 'locator', type: 'locator' }], + category: 'interaction', + icon: '🔍', + }, + 'Press Keys': { + params: [ + { name: 'locator', type: 'locator' }, + { name: 'keys', type: 'text' }, + ], + category: 'interaction', + icon: '⌨️', + }, + 'Wait Until Element Is Visible': { + params: [ + { name: 'locator', type: 'locator' }, + { name: 'timeout', type: 'text', placeholder: '10s', optional: true }, + ], + category: 'wait', + icon: '⏳', + }, + 'Wait Until Page Contains Element': { + params: [ + { name: 'locator', type: 'locator' }, + { name: 'timeout', type: 'text', placeholder: '10s', optional: true }, + ], + category: 'wait', + icon: '⏳', + }, + 'Element Should Be Visible': { + params: [{ name: 'locator', type: 'locator' }], + category: 'assertion', + icon: '👁️', + }, + 'Page Should Contain Element': { + params: [{ name: 'locator', type: 'locator' }], + category: 'assertion', + icon: '🔢', + }, + 'Element Text Should Be': { + params: [ + { name: 'locator', type: 'locator' }, + { name: 'expected', type: 'text' }, + ], + category: 'assertion', + icon: '📖', + }, + 'Title Should Be': { + params: [{ name: 'title', type: 'text' }], + category: 'assertion', + icon: '📄', + }, + 'Location Should Be': { + params: [{ name: 'url', type: 'url' }], + category: 'assertion', + icon: '🔗', + }, + 'Capture Page Screenshot': { + params: [{ name: 'filename', type: 'text', optional: true }], + category: 'utility', + icon: '📸', + }, + 'Close Browser': { + params: [], + category: 'navigation', + icon: '❌', + }, + 'Sleep': { + params: [{ name: 'time', type: 'text', placeholder: '3s' }], + category: 'utility', + icon: '💤', + }, +}; + +const CATEGORY_COLORS = { + navigation: '#3B82F6', + interaction: '#10B981', + wait: '#F59E0B', + assertion: '#8B5CF6', + utility: '#6B7280', +}; + +/** + * Parse a raw Robot Framework line into keyword + arguments. + * RF uses 2+ spaces (or tabs) as separator. + */ +export function parseLine(line) { + if (!line || !line.trim()) return null; + const trimmed = line.replace(/^\s+/, ''); + // Split on 2+ spaces or tab + const parts = trimmed.split(/\s{2,}|\t+/).filter(p => p.length > 0); + if (parts.length === 0) return null; + return { + keyword: parts[0], + args: parts.slice(1), + raw: line, + }; +} + +/** + * Look up keyword spec for the given keyword name and library. + */ +export function getKeywordSpec(keywordName, library) { + const specs = library === 'Browser' ? BROWSER_KEYWORDS : SELENIUM_KEYWORDS; + return specs[keywordName] || null; +} + +/** + * Get all keyword names for a library. + */ +export function getKeywordNames(library) { + const specs = library === 'Browser' ? BROWSER_KEYWORDS : SELENIUM_KEYWORDS; + return Object.keys(specs); +} + +/** + * Get the category color for a keyword. + */ +export function getCategoryColor(keywordName, library) { + const spec = getKeywordSpec(keywordName, library); + if (!spec) return CATEGORY_COLORS.utility; + return CATEGORY_COLORS[spec.category] || CATEGORY_COLORS.utility; +} + +/** + * Rebuild a raw RF line from keyword + args. + */ +export function buildLine(keyword, args) { + const parts = [keyword, ...args].filter(p => p && p.trim()); + return ' ' + parts.join(' '); +} + +export { BROWSER_KEYWORDS, SELENIUM_KEYWORDS, CATEGORY_COLORS }; diff --git a/src/logger.js b/src/logger.js index 906eb41..bf02338 100644 --- a/src/logger.js +++ b/src/logger.js @@ -1,4 +1,3 @@ -/* eslint-disable no-console */ // Centralized logger for the extension UI/background scripts. // Using a single file makes it easy to control verbosity and // avoids ESLint no-console warnings throughout the codebase. @@ -8,7 +7,7 @@ const DEBUG = false; const safeStringify = (v) => { try { return typeof v === 'object' ? JSON.stringify(v, null, 2) : String(v); - } catch (e) { + } catch (_e) { return String(v); } }; diff --git a/src/options.html b/src/options.html index e01f1b1..527bd94 100644 --- a/src/options.html +++ b/src/options.html @@ -1,17 +1,31 @@ - + - - - - + + RobotFramework Recorder - Options + + + + + +
-
+
+ + + + + + +

RF Recorder Options

+
+ +
-

Language

+

Language

@@ -19,16 +33,22 @@

Language

-
+
+ +
-

Custom Locators

+

Custom Locators

Add your own flavoured locators! Separate with a commas.
- - - + +
+ + +
+ + diff --git a/src/options.js b/src/options.js index d964e1f..955f2b7 100644 --- a/src/options.js +++ b/src/options.js @@ -1,11 +1,10 @@ -/* global document chrome t getCurrentLanguage setLanguage */ +import { t, getCurrentLanguage, setLanguage } from './translations.js'; -const host = chrome; -const storage = host.storage.local; +const storage = chrome.storage.local; let currentLanguage = 'en'; -function update() { +export function update() { const values = document.getElementById('custom-locators').value; const array = values ? values.split(',') : ['for', 'name', 'id', 'title', 'href', 'class']; storage.set({ locators: array }); @@ -33,23 +32,17 @@ async function changeLanguage(e) { } document.addEventListener('DOMContentLoaded', async () => { - // Load current language currentLanguage = await getCurrentLanguage(); const state = await storage.get({ locators: [] }); document.getElementById('custom-locators').value = state.locators.join(','); - // Update UI translations updateUITranslations(currentLanguage); - // Set language radio button document.getElementById(`lang_${currentLanguage}`).checked = true; document.getElementById('update').addEventListener('click', update); - // Language change event listener Array.from(document.getElementsByClassName('language-option')) .forEach(elem => elem.addEventListener('change', changeLanguage)); }); - -if (typeof exports !== 'undefined') exports.update = update; diff --git a/src/popup.html b/src/popup.html index 95220a5..edaacd2 100644 --- a/src/popup.html +++ b/src/popup.html @@ -1,55 +1,80 @@ - + - + RobotFramework Recorder - + + + + - -
- - + +
+ + + +
+ Ready +
+ + +
+
+ - - - - - +
- - - +
+ - + - -
+ -
- + +
+ + +
+