diff --git a/background.js b/background.js index 7cc7b40..51e174b 100644 --- a/background.js +++ b/background.js @@ -7,9 +7,73 @@ import { Question } from './src/Annotation.js'; import { ExportSessionCSV } from './src/ExportSessionCSV.js'; import { JSonSessionService } from './src/JSonSessionService.js'; import { getSystemInfo } from './src/browserInfo.js'; +import { GoogleDriveService } from './src/GoogleDriveService.js'; let session = new Session(); +// --- Google Drive state --- +const driveService = new GoogleDriveService(); +let driveAutoSave = false; +let driveFileId = null; +let driveSyncStatus = 'idle'; // idle | syncing | synced | error +let syncTimeout = null; + +async function loadDriveSettings() { + const data = await chrome.storage.local.get(['driveAutoSave', 'driveFileId']); + driveAutoSave = data.driveAutoSave || false; + driveFileId = data.driveFileId || null; + + // Try to restore token silently if auto-save was on + if (driveAutoSave) { + try { + await driveService.getToken(); + } catch (e) { + driveAutoSave = false; + await chrome.storage.local.set({ driveAutoSave: false }); + } + } +} + +async function saveDriveSettings() { + await chrome.storage.local.set({ driveAutoSave, driveFileId }); +} + +function scheduleDriveSync() { + if (!driveAutoSave || !driveService.isAuthenticated()) return; + if (syncTimeout) clearTimeout(syncTimeout); + syncTimeout = setTimeout(() => syncToDrive(), 1500); +} + +async function syncToDrive() { + if (!driveService.isAuthenticated()) return; + if (session.getAnnotations().length === 0) return; + + driveSyncStatus = 'syncing'; + try { + const jsonService = new JSonSessionService(); + const sessionJson = jsonService.getJSon(session); + const fileName = getDriveFileName(); + + const result = await driveService.uploadSession(sessionJson, fileName, driveFileId); + driveFileId = result.id; + driveSyncStatus = 'synced'; + await saveDriveSettings(); + } catch (error) { + console.error('Drive sync failed:', error); + driveSyncStatus = 'error'; + } +} + +function getDriveFileName() { + const date = new Date(session.getStartDateTime()); + const startDateTime = date.getFullYear() + + ('0' + (date.getMonth() + 1)).slice(-2) + + ('0' + date.getDate()).slice(-2) + '_' + + ('0' + date.getHours()).slice(-2) + + ('0' + date.getMinutes()).slice(-2); + return `ExploratorySession_${startDateTime}.json`; +} + // Función para guardar la sesión en el storage async function saveSession() { try { @@ -78,6 +142,9 @@ async function saveSession() { throw error; } } + + // Trigger Drive auto-sync after local save + scheduleDriveSync(); } // Función para cargar la sesión desde el storage @@ -113,8 +180,9 @@ async function loadSession() { } } -// Cargar la sesión al iniciar +// Cargar la sesión y configuración de Drive al iniciar loadSession(); +loadDriveSettings(); // Helper function for notifications of processing errors (before addAnnotation is called) function notifyProcessingError(annotationType, descriptionName, errorMessage = "") { @@ -328,6 +396,105 @@ chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { })) }); break; + + // --- Google Drive handlers --- + case "driveConnect": + driveService.authenticate() + .then(() => { + sendResponse({ status: "ok" }); + }) + .catch(error => { + sendResponse({ status: "error", error: error.message }); + }); + isAsync = true; + break; + + case "driveDisconnect": + driveService.disconnect() + .then(async () => { + driveAutoSave = false; + driveFileId = null; + driveSyncStatus = 'idle'; + await saveDriveSettings(); + sendResponse({ status: "ok" }); + }) + .catch(error => { + sendResponse({ status: "error", error: error.message }); + }); + isAsync = true; + break; + + case "driveGetStatus": + sendResponse({ + connected: driveService.isAuthenticated(), + autoSave: driveAutoSave, + syncStatus: driveSyncStatus, + fileId: driveFileId + }); + break; + + case "driveSetAutoSave": + driveAutoSave = request.enabled; + saveDriveSettings().then(() => { + sendResponse({ status: "ok", autoSave: driveAutoSave }); + }); + isAsync = true; + break; + + case "driveSaveNow": + syncToDrive() + .then(() => { + sendResponse({ status: "ok", syncStatus: driveSyncStatus, fileId: driveFileId }); + }) + .catch(error => { + sendResponse({ status: "error", error: error.message, syncStatus: driveSyncStatus }); + }); + isAsync = true; + break; + + case "driveListSessions": + driveService.listSessions() + .then(files => { + sendResponse({ status: "ok", files }); + }) + .catch(error => { + sendResponse({ status: "error", error: error.message }); + }); + isAsync = true; + break; + + case "driveLoadSession": + driveService.downloadSession(request.fileId) + .then(jsonData => { + if (importSessionJSon(jsonData)) { + driveFileId = request.fileId; + return saveDriveSettings().then(() => saveSession()).then(() => { + sendResponse({ status: "ok" }); + }); + } else { + sendResponse({ status: "error", error: "Invalid session data" }); + } + }) + .catch(error => { + sendResponse({ status: "error", error: error.message }); + }); + isAsync = true; + break; + + case "driveDeleteSession": + driveService.deleteSession(request.fileId) + .then(() => { + if (driveFileId === request.fileId) { + driveFileId = null; + saveDriveSettings(); + } + sendResponse({ status: "ok" }); + }) + .catch(error => { + sendResponse({ status: "error", error: error.message }); + }); + isAsync = true; + break; } return isAsync; // Return true only if sendResponse is used asynchronously in any of the handled cases. }); @@ -511,6 +678,8 @@ async function startSession() { async function clearSession() { session.clearAnnotations(); + driveFileId = null; + await saveDriveSettings(); await saveSession(); } diff --git a/css/popUp.css b/css/popUp.css index 9a71962..bf802de 100644 --- a/css/popUp.css +++ b/css/popUp.css @@ -546,4 +546,325 @@ button input.file-input { #resetNo.cancelButton:active { transform: translateY(0); +} + +/* ============================================ + Google Drive Section + ============================================ */ +.drive-section { + margin-top: 10px; + padding: 10px 8px 8px; + border-top: 1px solid var(--color-border); +} + +.drive-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 8px; +} + +.drive-label { + font-size: 0.75rem; + font-weight: 600; + color: var(--color-text-secondary); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.drive-status { + font-size: 0.7rem; + font-weight: 500; + padding: 2px 8px; + border-radius: 10px; +} + +.drive-status-synced { + color: #16a34a; + background-color: #f0fdf4; +} + +.drive-status-syncing { + color: var(--color-primary); + background-color: #eef2ff; +} + +.drive-status-error { + color: var(--color-bug); + background-color: var(--color-bug-light); +} + +/* Connect button */ +.drive-connect-btn { + width: 100%; + background: linear-gradient(135deg, #4285f4 0%, #34a853 100%); + color: #ffffff; + border: none; + border-radius: var(--radius-md); + padding: 8px 16px; + font-weight: 500; + font-size: 0.85rem; + cursor: pointer; + transition: all var(--transition); +} + +.drive-connect-btn:hover { + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(66, 133, 244, 0.3); + color: #ffffff; +} + +.drive-connect-btn:active { + transform: translateY(0); +} + +.drive-connect-btn:disabled { + opacity: 0.7; + cursor: not-allowed; + transform: none; +} + +/* Connected controls row */ +.drive-controls { + display: flex; + align-items: center; + gap: 6px; +} + +/* Auto-save toggle */ +.drive-autosave-toggle { + display: flex; + align-items: center; + gap: 4px; + cursor: pointer; + margin: 0; + flex-shrink: 0; +} + +.drive-autosave-toggle input[type="checkbox"] { + display: none; +} + +.drive-toggle-slider { + width: 32px; + height: 18px; + background-color: #cbd5e1; + border-radius: 9px; + position: relative; + transition: background-color var(--transition); +} + +.drive-toggle-slider::after { + content: ''; + position: absolute; + width: 14px; + height: 14px; + border-radius: 50%; + background-color: #ffffff; + top: 2px; + left: 2px; + transition: transform var(--transition); + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15); +} + +.drive-autosave-toggle input:checked+.drive-toggle-slider { + background-color: #4285f4; +} + +.drive-autosave-toggle input:checked+.drive-toggle-slider::after { + transform: translateX(14px); +} + +.drive-toggle-label { + font-size: 0.7rem; + color: var(--color-text-secondary); + font-weight: 500; +} + +/* Drive action buttons (save/load) */ +.drive-action-btn { + background-repeat: no-repeat; + background-size: 18px 18px; + background-position: center; + min-width: 34px; + min-height: 34px; + background-color: var(--color-surface); + border: 1.5px solid var(--color-border); + border-radius: var(--radius-md); + transition: all var(--transition); + cursor: pointer; + padding: 0; +} + +.drive-action-btn:hover { + border-color: #4285f4; + transform: translateY(-1px); + box-shadow: 0 3px 8px rgba(66, 133, 244, 0.15); +} + +.drive-action-btn:active { + transform: translateY(0); +} + +.drive-action-btn:disabled { + opacity: 0.5; + cursor: not-allowed; + transform: none; +} + +#driveSaveBtn { + background-image: url("../images/cloud-upload.svg"); +} + +#driveLoadBtn.drive-load-icon { + background-image: url("../images/cloud-download.svg"); +} + +/* Disconnect button */ +.drive-disconnect-btn { + min-width: 28px; + min-height: 28px; + padding: 0; + margin-left: auto; + background-color: transparent; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + color: var(--color-text-secondary); + font-size: 1rem; + line-height: 1; + cursor: pointer; + transition: all var(--transition); +} + +.drive-disconnect-btn:hover { + background-color: var(--color-bug-light); + border-color: var(--color-bug); + color: var(--color-bug); +} + +/* Load from Drive panel */ +#driveLoadPanel { + margin-top: 8px; + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + overflow: hidden; +} + +.drive-load-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 10px; + background-color: #f8fafc; + border-bottom: 1px solid var(--color-border); + font-size: 0.8rem; + font-weight: 600; + color: var(--color-text); +} + +.drive-load-close { + background: none; + border: none; + font-size: 1.1rem; + color: var(--color-text-secondary); + cursor: pointer; + padding: 0 4px; + line-height: 1; +} + +.drive-load-close:hover { + color: var(--color-bug); +} + +.drive-file-list { + max-height: 200px; + overflow-y: auto; +} + +.drive-loading, +.drive-empty { + padding: 16px; + text-align: center; + font-size: 0.8rem; + color: var(--color-text-secondary); +} + +.drive-file-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 10px; + border-bottom: 1px solid #f1f5f9; + transition: background-color var(--transition); +} + +.drive-file-item:last-child { + border-bottom: none; +} + +.drive-file-item:hover { + background-color: #f8fafc; +} + +.drive-file-info { + display: flex; + flex-direction: column; + min-width: 0; + flex: 1; +} + +.drive-file-name { + font-size: 0.78rem; + font-weight: 500; + color: var(--color-text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.drive-file-date { + font-size: 0.68rem; + color: var(--color-text-secondary); +} + +.drive-file-actions { + display: flex; + gap: 4px; + flex-shrink: 0; + margin-left: 8px; +} + +.drive-file-load { + font-size: 0.7rem; + padding: 3px 10px; + border: 1px solid #4285f4; + border-radius: var(--radius-sm); + background-color: #ffffff; + color: #4285f4; + cursor: pointer; + font-weight: 500; + transition: all var(--transition); +} + +.drive-file-load:hover { + background-color: #4285f4; + color: #ffffff; +} + +.drive-file-delete { + font-size: 0.9rem; + padding: 1px 6px; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + background-color: #ffffff; + color: var(--color-text-secondary); + cursor: pointer; + line-height: 1; + transition: all var(--transition); +} + +.drive-file-delete:hover { + background-color: var(--color-bug-light); + border-color: var(--color-bug); + color: var(--color-bug); } \ No newline at end of file diff --git a/images/cloud-download.svg b/images/cloud-download.svg new file mode 100644 index 0000000..ed5e03c --- /dev/null +++ b/images/cloud-download.svg @@ -0,0 +1,4 @@ + + + + diff --git a/images/cloud-upload.svg b/images/cloud-upload.svg new file mode 100644 index 0000000..5a8ad23 --- /dev/null +++ b/images/cloud-upload.svg @@ -0,0 +1,4 @@ + + + + diff --git a/images/gdrive.svg b/images/gdrive.svg new file mode 100644 index 0000000..789354a --- /dev/null +++ b/images/gdrive.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/jest.setup.js b/jest.setup.js index 382f5cf..d87fa0a 100644 --- a/jest.setup.js +++ b/jest.setup.js @@ -2,6 +2,7 @@ global.chrome = { runtime: { getManifest: () => ({ version: '1.0.0' }), // Basic mock + lastError: null, // Add other chrome.runtime APIs if needed by tests }, // Mock other chrome.* APIs as necessary @@ -16,6 +17,10 @@ global.chrome = { tabs: { query: jest.fn((queryInfo, callback) => callback([{ id: 1, url: 'http://example.com' }])), // Add other chrome.tabs APIs if needed + }, + identity: { + getAuthToken: jest.fn((options, callback) => callback('mock-token-123')), + removeCachedAuthToken: jest.fn((details, callback) => callback()), } // Add more chrome API mocks as identified during testing }; diff --git a/js/popup.js b/js/popup.js index 1b56e6e..b327c17 100644 --- a/js/popup.js +++ b/js/popup.js @@ -12,7 +12,9 @@ window.onload = function () { function initElements() { annotationListeners(); exportListeners(); + driveListeners(); updateCounters(); + updateDriveUI(); registerPopupMessageListener(); // Added listener registration $(function () { $('[data-toggle="tooltip"]').tooltip() @@ -480,3 +482,179 @@ document.addEventListener('DOMContentLoaded', function () { $("#resetConfirmation").slideUp(); }); }); + +/* ============================================ + Google Drive Integration + ============================================ */ + +function driveListeners() { + $(document).on('click', '#driveConnectBtn', driveConnect); + $(document).on('click', '#driveDisconnectBtn', driveDisconnect); + $(document).on('click', '#driveSaveBtn', driveSaveNow); + $(document).on('click', '#driveLoadBtn', driveShowLoadPanel); + $(document).on('click', '#driveLoadClose', driveHideLoadPanel); + $(document).on('change', '#driveAutoSaveToggle', driveToggleAutoSave); +} + +function updateDriveUI() { + chrome.runtime.sendMessage({ type: "driveGetStatus" }, function (response) { + if (chrome.runtime.lastError || !response) return; + + if (response.connected) { + $("#driveDisconnected").hide(); + $("#driveConnected").show(); + $("#driveAutoSaveToggle").prop('checked', response.autoSave); + updateDriveSyncStatusUI(response.syncStatus); + } else { + $("#driveDisconnected").show(); + $("#driveConnected").hide(); + $("#driveSyncStatus").text("").removeClass(); + $("#driveSyncStatus").addClass("drive-status"); + } + }); +} + +function updateDriveSyncStatusUI(status) { + var $el = $("#driveSyncStatus"); + $el.removeClass("drive-status-synced drive-status-syncing drive-status-error"); + + switch (status) { + case "synced": + $el.text("Synced").addClass("drive-status-synced"); + break; + case "syncing": + $el.text("Syncing...").addClass("drive-status-syncing"); + break; + case "error": + $el.text("Error").addClass("drive-status-error"); + break; + default: + $el.text(""); + break; + } +} + +function driveConnect() { + $("#driveConnectBtn").prop('disabled', true).text("Connecting..."); + chrome.runtime.sendMessage({ type: "driveConnect" }, function (response) { + $("#driveConnectBtn").prop('disabled', false).text("Connect"); + if (response && response.status === "ok") { + updateDriveUI(); + } else { + var errorMsg = response && response.error ? response.error : "Connection failed"; + console.error("Drive connect failed:", errorMsg); + } + }); +} + +function driveDisconnect() { + chrome.runtime.sendMessage({ type: "driveDisconnect" }, function (response) { + updateDriveUI(); + driveHideLoadPanel(); + }); +} + +function driveToggleAutoSave() { + var enabled = $("#driveAutoSaveToggle").is(':checked'); + chrome.runtime.sendMessage({ type: "driveSetAutoSave", enabled: enabled }, function (response) { + if (response && response.status === "ok") { + // If enabling auto-save, trigger an immediate sync + if (enabled) { + driveSaveNow(); + } + } + }); +} + +function driveSaveNow() { + updateDriveSyncStatusUI("syncing"); + $("#driveSaveBtn").prop('disabled', true); + chrome.runtime.sendMessage({ type: "driveSaveNow" }, function (response) { + $("#driveSaveBtn").prop('disabled', false); + if (response) { + updateDriveSyncStatusUI(response.syncStatus || "error"); + } + }); +} + +function driveShowLoadPanel() { + $("#driveLoadPanel").slideDown(); + $("#driveFileList").html('
Loading...
'); + + chrome.runtime.sendMessage({ type: "driveListSessions" }, function (response) { + if (response && response.status === "ok") { + renderDriveFileList(response.files); + } else { + $("#driveFileList").html('
Could not load sessions
'); + } + }); +} + +function driveHideLoadPanel() { + $("#driveLoadPanel").slideUp(); +} + +function renderDriveFileList(files) { + var $list = $("#driveFileList"); + $list.empty(); + + if (!files || files.length === 0) { + $list.html('
No sessions saved yet
'); + return; + } + + files.forEach(function (file) { + var date = new Date(file.modifiedTime); + var dateStr = date.toLocaleDateString() + " " + + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + var displayName = file.name.replace('.json', '').replace('ExploratorySession_', '').replace(/_/g, ' '); + + var $item = $( + '
' + + '
' + + ' ' + displayName + '' + + ' ' + dateStr + '' + + '
' + + '
' + + ' ' + + ' ' + + '
' + + '
' + ); + + $item.find('.drive-file-load').on('click', function () { + driveLoadSession(file.id); + }); + + $item.find('.drive-file-delete').on('click', function () { + driveDeleteSession(file.id, $item); + }); + + $list.append($item); + }); +} + +function driveLoadSession(fileId) { + $("#driveFileList").html('
Loading session...
'); + chrome.runtime.sendMessage({ type: "driveLoadSession", fileId: fileId }, function (response) { + if (response && response.status === "ok") { + updateCounters(); + driveHideLoadPanel(); + updateDriveUI(); + } else { + var errorMsg = response && response.error ? response.error : "Load failed"; + $("#driveFileList").html('
' + errorMsg + '
'); + } + }); +} + +function driveDeleteSession(fileId, $item) { + $item.css('opacity', '0.5'); + chrome.runtime.sendMessage({ type: "driveDeleteSession", fileId: fileId }, function (response) { + if (response && response.status === "ok") { + $item.slideUp(200, function () { $item.remove(); }); + } else { + $item.css('opacity', '1'); + } + }); +} diff --git a/manifest.json b/manifest.json index 9613cf5..aa58cc7 100644 --- a/manifest.json +++ b/manifest.json @@ -9,8 +9,15 @@ "activeTab", "downloads", "scripting", - "notifications" + "notifications", + "identity" ], + "oauth2": { + "client_id": "YOUR_CLIENT_ID.apps.googleusercontent.com", + "scopes": [ + "https://www.googleapis.com/auth/drive.file" + ] + }, "action": { "default_popup": "popup.html", "default_icon": { diff --git a/popup.html b/popup.html index 08e988f..668f5c1 100644 --- a/popup.html +++ b/popup.html @@ -163,6 +163,46 @@
Add new Annotation
+ + +
+
+ Google Drive + +
+ +
+ +
+ + + + +
diff --git a/src/GoogleDriveService.js b/src/GoogleDriveService.js new file mode 100644 index 0000000..95ac720 --- /dev/null +++ b/src/GoogleDriveService.js @@ -0,0 +1,196 @@ +const DRIVE_FOLDER_NAME = 'Exploratory Testing Sessions'; +const DRIVE_API = 'https://www.googleapis.com/drive/v3/files'; +const DRIVE_UPLOAD_API = 'https://www.googleapis.com/upload/drive/v3/files'; + +export class GoogleDriveService { + constructor() { + this.token = null; + this.folderId = null; + } + + async authenticate() { + return new Promise((resolve, reject) => { + chrome.identity.getAuthToken({ interactive: true }, (token) => { + if (chrome.runtime.lastError) { + reject(new Error(chrome.runtime.lastError.message)); + return; + } + this.token = token; + resolve(token); + }); + }); + } + + async disconnect() { + if (!this.token) return; + + // Revoke the token on Google's side + try { + await fetch(`https://accounts.google.com/o/oauth2/revoke?token=${this.token}`); + } catch (e) { + // Best effort revocation + } + + return new Promise((resolve) => { + chrome.identity.removeCachedAuthToken({ token: this.token }, () => { + this.token = null; + this.folderId = null; + resolve(); + }); + }); + } + + async getToken() { + if (this.token) return this.token; + return new Promise((resolve, reject) => { + chrome.identity.getAuthToken({ interactive: false }, (token) => { + if (chrome.runtime.lastError || !token) { + reject(new Error('Not authenticated')); + return; + } + this.token = token; + resolve(token); + }); + }); + } + + isAuthenticated() { + return this.token !== null; + } + + async fetchApi(url, options = {}) { + const token = await this.getToken(); + const response = await fetch(url, { + ...options, + headers: { + 'Authorization': `Bearer ${token}`, + ...options.headers, + }, + }); + + if (response.status === 401) { + // Token expired, remove cached token and get a new one + await new Promise(resolve => { + chrome.identity.removeCachedAuthToken({ token: this.token }, resolve); + }); + this.token = null; + const newToken = await this.getToken(); + return fetch(url, { + ...options, + headers: { + 'Authorization': `Bearer ${newToken}`, + ...options.headers, + }, + }); + } + + return response; + } + + async findOrCreateFolder() { + if (this.folderId) return this.folderId; + + // Search for existing folder + const query = `name='${DRIVE_FOLDER_NAME}' and mimeType='application/vnd.google-apps.folder' and trashed=false`; + const searchUrl = `${DRIVE_API}?q=${encodeURIComponent(query)}&fields=files(id,name)`; + + const response = await this.fetchApi(searchUrl); + const data = await response.json(); + + if (data.files && data.files.length > 0) { + this.folderId = data.files[0].id; + return this.folderId; + } + + // Create folder + const createResponse = await this.fetchApi(DRIVE_API, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: DRIVE_FOLDER_NAME, + mimeType: 'application/vnd.google-apps.folder', + }), + }); + + const folder = await createResponse.json(); + this.folderId = folder.id; + return this.folderId; + } + + async uploadSession(sessionJson, fileName, existingFileId = null) { + const folderId = await this.findOrCreateFolder(); + + const metadata = { + name: fileName, + mimeType: 'application/json', + }; + + if (!existingFileId) { + metadata.parents = [folderId]; + } + + const boundary = 'exploratory_testing_boundary'; + const body = + `--${boundary}\r\n` + + `Content-Type: application/json; charset=UTF-8\r\n\r\n` + + `${JSON.stringify(metadata)}\r\n` + + `--${boundary}\r\n` + + `Content-Type: application/json\r\n\r\n` + + `${sessionJson}\r\n` + + `--${boundary}--`; + + let url, method; + if (existingFileId) { + url = `${DRIVE_UPLOAD_API}/${existingFileId}?uploadType=multipart&fields=id,name,modifiedTime`; + method = 'PATCH'; + } else { + url = `${DRIVE_UPLOAD_API}?uploadType=multipart&fields=id,name,modifiedTime`; + method = 'POST'; + } + + const response = await this.fetchApi(url, { + method, + headers: { + 'Content-Type': `multipart/related; boundary=${boundary}`, + }, + body, + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Upload failed: ${response.status} - ${error}`); + } + + return response.json(); + } + + async listSessions() { + const folderId = await this.findOrCreateFolder(); + const query = `'${folderId}' in parents and mimeType='application/json' and trashed=false`; + const url = `${DRIVE_API}?q=${encodeURIComponent(query)}&fields=files(id,name,modifiedTime,size)&orderBy=modifiedTime desc&pageSize=20`; + + const response = await this.fetchApi(url); + const data = await response.json(); + return data.files || []; + } + + async downloadSession(fileId) { + const url = `${DRIVE_API}/${fileId}?alt=media`; + const response = await this.fetchApi(url); + + if (!response.ok) { + throw new Error(`Download failed: ${response.status}`); + } + + return response.text(); + } + + async deleteSession(fileId) { + const url = `${DRIVE_API}/${fileId}`; + const response = await this.fetchApi(url, { method: 'DELETE' }); + + if (!response.ok && response.status !== 204) { + throw new Error(`Delete failed: ${response.status}`); + } + } +} diff --git a/test/spec/GoogleDriveService.test.js b/test/spec/GoogleDriveService.test.js new file mode 100644 index 0000000..fc4641b --- /dev/null +++ b/test/spec/GoogleDriveService.test.js @@ -0,0 +1,329 @@ +import { GoogleDriveService } from '../../src/GoogleDriveService.js'; + +// Mock global fetch +global.fetch = jest.fn(); + +describe('GoogleDriveService', () => { + let service; + + beforeEach(() => { + service = new GoogleDriveService(); + jest.clearAllMocks(); + chrome.runtime.lastError = null; + chrome.identity.getAuthToken.mockImplementation((options, callback) => callback('mock-token-123')); + chrome.identity.removeCachedAuthToken.mockImplementation((details, callback) => callback()); + }); + + describe('authenticate', () => { + it('should get auth token interactively', async () => { + const token = await service.authenticate(); + + expect(token).toBe('mock-token-123'); + expect(chrome.identity.getAuthToken).toHaveBeenCalledWith( + { interactive: true }, + expect.any(Function) + ); + expect(service.isAuthenticated()).toBe(true); + }); + + it('should reject when authentication fails', async () => { + chrome.identity.getAuthToken.mockImplementation((options, callback) => { + chrome.runtime.lastError = { message: 'User denied access' }; + callback(undefined); + chrome.runtime.lastError = null; + }); + + await expect(service.authenticate()).rejects.toThrow('User denied access'); + expect(service.isAuthenticated()).toBe(false); + }); + }); + + describe('disconnect', () => { + it('should revoke token and clear state', async () => { + await service.authenticate(); + fetch.mockResolvedValueOnce({ ok: true }); + + await service.disconnect(); + + expect(chrome.identity.removeCachedAuthToken).toHaveBeenCalledWith( + { token: 'mock-token-123' }, + expect.any(Function) + ); + expect(service.isAuthenticated()).toBe(false); + }); + + it('should do nothing when not authenticated', async () => { + await service.disconnect(); + expect(chrome.identity.removeCachedAuthToken).not.toHaveBeenCalled(); + }); + }); + + describe('getToken', () => { + it('should return cached token if available', async () => { + await service.authenticate(); + jest.clearAllMocks(); + + const token = await service.getToken(); + expect(token).toBe('mock-token-123'); + expect(chrome.identity.getAuthToken).not.toHaveBeenCalled(); + }); + + it('should get token silently when not cached', async () => { + const token = await service.getToken(); + expect(token).toBe('mock-token-123'); + expect(chrome.identity.getAuthToken).toHaveBeenCalledWith( + { interactive: false }, + expect.any(Function) + ); + }); + + it('should reject when silent auth fails', async () => { + chrome.identity.getAuthToken.mockImplementation((options, callback) => { + chrome.runtime.lastError = { message: 'Not signed in' }; + callback(undefined); + chrome.runtime.lastError = null; + }); + + await expect(service.getToken()).rejects.toThrow('Not authenticated'); + }); + }); + + describe('isAuthenticated', () => { + it('should return false initially', () => { + expect(service.isAuthenticated()).toBe(false); + }); + + it('should return true after authentication', async () => { + await service.authenticate(); + expect(service.isAuthenticated()).toBe(true); + }); + }); + + describe('findOrCreateFolder', () => { + beforeEach(async () => { + await service.authenticate(); + }); + + it('should return cached folder ID if available', async () => { + // First call - search returns existing folder + fetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: () => Promise.resolve({ files: [{ id: 'folder-123', name: 'Exploratory Testing Sessions' }] }) + }); + + const folderId1 = await service.findOrCreateFolder(); + expect(folderId1).toBe('folder-123'); + + // Second call should use cache + jest.clearAllMocks(); + const folderId2 = await service.findOrCreateFolder(); + expect(folderId2).toBe('folder-123'); + expect(fetch).not.toHaveBeenCalled(); + }); + + it('should find existing folder', async () => { + fetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: () => Promise.resolve({ files: [{ id: 'folder-123', name: 'Exploratory Testing Sessions' }] }) + }); + + const folderId = await service.findOrCreateFolder(); + expect(folderId).toBe('folder-123'); + }); + + it('should create folder when not found', async () => { + // Search returns empty + fetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: () => Promise.resolve({ files: [] }) + }); + + // Create returns new folder + fetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: () => Promise.resolve({ id: 'new-folder-456' }) + }); + + const folderId = await service.findOrCreateFolder(); + expect(folderId).toBe('new-folder-456'); + expect(fetch).toHaveBeenCalledTimes(2); + }); + }); + + describe('uploadSession', () => { + beforeEach(async () => { + await service.authenticate(); + // Mock findOrCreateFolder + fetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: () => Promise.resolve({ files: [{ id: 'folder-123' }] }) + }); + }); + + it('should create new file when no existing ID', async () => { + fetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: () => Promise.resolve({ id: 'file-789', name: 'test.json', modifiedTime: '2026-01-01T00:00:00Z' }) + }); + + const result = await service.uploadSession('{"test":"data"}', 'test.json'); + expect(result.id).toBe('file-789'); + + // Check that the POST was to the upload endpoint (not PATCH) + const uploadCall = fetch.mock.calls[1]; + expect(uploadCall[0]).toContain('uploadType=multipart'); + expect(uploadCall[1].method).toBe('POST'); + }); + + it('should update existing file when ID provided', async () => { + fetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: () => Promise.resolve({ id: 'file-789', name: 'test.json', modifiedTime: '2026-01-01T00:00:00Z' }) + }); + + const result = await service.uploadSession('{"test":"data"}', 'test.json', 'file-789'); + expect(result.id).toBe('file-789'); + + const uploadCall = fetch.mock.calls[1]; + expect(uploadCall[0]).toContain('file-789'); + expect(uploadCall[1].method).toBe('PATCH'); + }); + + it('should throw on upload failure', async () => { + fetch.mockResolvedValueOnce({ + ok: false, + status: 500, + text: () => Promise.resolve('Server error') + }); + + await expect(service.uploadSession('{"test":"data"}', 'test.json')).rejects.toThrow('Upload failed'); + }); + }); + + describe('listSessions', () => { + beforeEach(async () => { + await service.authenticate(); + }); + + it('should return list of session files', async () => { + // findOrCreateFolder + fetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: () => Promise.resolve({ files: [{ id: 'folder-123' }] }) + }); + + // listSessions + fetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: () => Promise.resolve({ + files: [ + { id: 'f1', name: 'Session1.json', modifiedTime: '2026-01-01T00:00:00Z', size: '1024' }, + { id: 'f2', name: 'Session2.json', modifiedTime: '2026-01-02T00:00:00Z', size: '2048' } + ] + }) + }); + + const files = await service.listSessions(); + expect(files).toHaveLength(2); + expect(files[0].id).toBe('f1'); + }); + + it('should return empty array when no files', async () => { + fetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: () => Promise.resolve({ files: [{ id: 'folder-123' }] }) + }); + + fetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: () => Promise.resolve({ files: [] }) + }); + + const files = await service.listSessions(); + expect(files).toHaveLength(0); + }); + }); + + describe('downloadSession', () => { + beforeEach(async () => { + await service.authenticate(); + }); + + it('should download file content as text', async () => { + const sessionData = '{"StartDateTime":123,"annotations":[]}'; + fetch.mockResolvedValueOnce({ + ok: true, + status: 200, + text: () => Promise.resolve(sessionData) + }); + + const result = await service.downloadSession('file-123'); + expect(result).toBe(sessionData); + }); + + it('should throw on download failure', async () => { + fetch.mockResolvedValueOnce({ + ok: false, + status: 404 + }); + + await expect(service.downloadSession('file-123')).rejects.toThrow('Download failed: 404'); + }); + }); + + describe('deleteSession', () => { + beforeEach(async () => { + await service.authenticate(); + }); + + it('should delete file successfully', async () => { + fetch.mockResolvedValueOnce({ + ok: true, + status: 204 + }); + + await expect(service.deleteSession('file-123')).resolves.not.toThrow(); + }); + + it('should throw on delete failure', async () => { + fetch.mockResolvedValueOnce({ + ok: false, + status: 500 + }); + + await expect(service.deleteSession('file-123')).rejects.toThrow('Delete failed: 500'); + }); + }); + + describe('fetchApi - token refresh on 401', () => { + it('should retry with new token on 401', async () => { + await service.authenticate(); + + // First call returns 401 + fetch.mockResolvedValueOnce({ status: 401 }); + + // After token refresh, retry succeeds + chrome.identity.getAuthToken.mockImplementation((options, callback) => callback('new-token-456')); + fetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: () => Promise.resolve({ files: [] }) + }); + + const response = await service.fetchApi('https://www.googleapis.com/drive/v3/files'); + expect(response.status).toBe(200); + expect(chrome.identity.removeCachedAuthToken).toHaveBeenCalled(); + }); + }); +});