diff --git a/src/api/endpoints.js b/src/api/endpoints.js index 5c1fc1b1..29cdfbfa 100644 --- a/src/api/endpoints.js +++ b/src/api/endpoints.js @@ -237,9 +237,10 @@ export async function searchComparisons(client, name, filters = {}) { throw new VizzlyError('name is required and must be a non-empty string'); } - let { branch, limit = 50, offset = 0 } = filters; + let { branch, project, limit = 50, offset = 0 } = filters; let params = { name, limit: String(limit), offset: String(offset) }; if (branch) params.branch = branch; + if (project) params.project = project; let endpoint = buildEndpointWithParams('/api/sdk/comparisons/search', params); return client.request(endpoint); diff --git a/src/cli.js b/src/cli.js index 048ccd70..c8c26eb3 100644 --- a/src/cli.js +++ b/src/cli.js @@ -22,13 +22,6 @@ import { loginCommand, validateLoginOptions } from './commands/login.js'; import { logoutCommand, validateLogoutOptions } from './commands/logout.js'; import { orgsCommand, validateOrgsOptions } from './commands/orgs.js'; import { previewCommand, validatePreviewOptions } from './commands/preview.js'; -import { - projectListCommand, - projectRemoveCommand, - projectSelectCommand, - projectTokenCommand, - validateProjectOptions, -} from './commands/project.js'; import { projectsCommand, validateProjectsOptions, @@ -138,17 +131,6 @@ const formatHelp = (cmd, helper) => { title: 'Account', names: ['login', 'logout', 'whoami', 'orgs', 'projects'], }, - { - key: 'project', - icon: '▸', - title: 'Projects', - names: [ - 'project:select', - 'project:list', - 'project:token', - 'project:remove', - ], - }, ]; let grouped = { @@ -157,7 +139,6 @@ const formatHelp = (cmd, helper) => { setup: [], advanced: [], auth: [], - project: [], other: [], }; @@ -644,6 +625,7 @@ program 'Filter by status (created, pending, processing, completed, failed)' ) .option('--environment ', 'Filter by environment') + .option('-p, --project ', 'Filter by project slug') .option( '--limit ', 'Maximum results to return (1-250)', @@ -658,6 +640,7 @@ program Examples: $ vizzly builds # List recent builds $ vizzly builds --branch main # Filter by branch + $ vizzly builds --project abc123 # Filter by project $ vizzly builds --status completed # Filter by status $ vizzly builds -b abc123-def456 # Get specific build by ID $ vizzly builds -b abc123 --comparisons # Include comparisons @@ -695,6 +678,7 @@ program 50 ) .option('--offset ', 'Skip first N results', val => parseInt(val, 10), 0) + .option('-p, --project ', 'Filter by project slug') .addHelpText( 'after', ` @@ -1157,53 +1141,6 @@ program await whoamiCommand(options, globalOptions); }); -program - .command('project:select') - .description('Configure project for current directory') - .option('--api-url ', 'API URL override') - .action(async options => { - const globalOptions = program.opts(); - - // Validate options - const validationErrors = validateProjectOptions(options); - if (validationErrors.length > 0) { - output.error('Validation errors:'); - for (let error of validationErrors) { - output.printErr(` - ${error}`); - } - process.exit(1); - } - - await projectSelectCommand(options, globalOptions); - }); - -program - .command('project:list') - .description('Show all configured projects') - .action(async options => { - const globalOptions = program.opts(); - - await projectListCommand(options, globalOptions); - }); - -program - .command('project:token') - .description('Show project token for current directory') - .action(async options => { - const globalOptions = program.opts(); - - await projectTokenCommand(options, globalOptions); - }); - -program - .command('project:remove') - .description('Remove project configuration for current directory') - .action(async options => { - const globalOptions = program.opts(); - - await projectRemoveCommand(options, globalOptions); - }); - // Save user's PATH for menubar app (non-blocking, runs in background) // This auto-configures the menubar app so it can find npx/node saveUserPath().catch(() => {}); diff --git a/src/commands/builds.js b/src/commands/builds.js index 2274763a..13ed7317 100644 --- a/src/commands/builds.js +++ b/src/commands/builds.js @@ -85,6 +85,7 @@ export async function buildsCommand( if (options.branch) filters.branch = options.branch; if (options.status) filters.status = options.status; if (options.environment) filters.environment = options.environment; + if (options.project) filters.project = options.project; let response = await getBuilds(client, filters); output.stopSpinner(); diff --git a/src/commands/comparisons.js b/src/commands/comparisons.js index 4bd9c0e8..ecdfd8bc 100644 --- a/src/commands/comparisons.js +++ b/src/commands/comparisons.js @@ -129,6 +129,7 @@ export async function comparisonsCommand( output.startSpinner('Searching comparisons...'); let filters = { branch: options.branch, + project: options.project, limit: options.limit || 50, offset: options.offset || 0, }; diff --git a/src/commands/config-cmd.js b/src/commands/config-cmd.js index c5368f0b..53bac437 100644 --- a/src/commands/config-cmd.js +++ b/src/commands/config-cmd.js @@ -2,9 +2,7 @@ * Config command - query and display configuration */ -import { resolve } from 'node:path'; import { loadConfig as defaultLoadConfig } from '../utils/config-loader.js'; -import { getProjectMapping as defaultGetProjectMapping } from '../utils/global-config.js'; import * as defaultOutput from '../utils/output.js'; /** @@ -22,7 +20,6 @@ export async function configCommand( ) { let { loadConfig = defaultLoadConfig, - getProjectMapping = defaultGetProjectMapping, output = defaultOutput, exit = code => process.exit(code), } = deps; @@ -41,10 +38,6 @@ export async function configCommand( }); let configFile = config._configPath || null; - // Get project mapping if available - let currentDir = resolve(process.cwd()); - let projectMapping = await getProjectMapping(currentDir); - // Build the config object to display let displayConfig = { server: config.server || { port: 47392, timeout: 30000 }, @@ -94,13 +87,6 @@ export async function configCommand( output.data({ configFile, config: displayConfig, - project: projectMapping - ? { - name: projectMapping.projectName, - slug: projectMapping.projectSlug, - organization: projectMapping.organizationSlug, - } - : null, }); output.cleanup(); return; @@ -117,16 +103,6 @@ export async function configCommand( } output.blank(); - // Project context if available - if (projectMapping) { - output.labelValue( - 'Project', - `${projectMapping.projectName} (${projectMapping.projectSlug})` - ); - output.labelValue('Organization', projectMapping.organizationSlug); - output.blank(); - } - // Display configuration sections displaySection(output, 'Server', displayConfig.server); displaySection(output, 'Comparison', displayConfig.comparison); diff --git a/src/commands/project.js b/src/commands/project.js deleted file mode 100644 index 9bd914d3..00000000 --- a/src/commands/project.js +++ /dev/null @@ -1,473 +0,0 @@ -/** - * Project management commands - * Select, list, and manage project tokens - */ - -import { resolve } from 'node:path'; -import readline from 'node:readline'; -import { - createAuthClient, - createTokenStore, - getAuthTokens, - whoami, -} from '../auth/index.js'; -import { getApiUrl } from '../utils/environment-config.js'; -import { - deleteProjectMapping, - getProjectMapping, - getProjectMappings, - saveProjectMapping, -} from '../utils/global-config.js'; -import * as output from '../utils/output.js'; - -/** - * Project select command - configure project for current directory - * @param {Object} options - Command options - * @param {Object} globalOptions - Global CLI options - */ -export async function projectSelectCommand(options = {}, globalOptions = {}) { - output.configure({ - json: globalOptions.json, - verbose: globalOptions.verbose, - color: !globalOptions.noColor, - }); - - try { - output.header('project:select'); - - // Check authentication - let auth = await getAuthTokens(); - if (!auth || !auth.accessToken) { - output.error('Not authenticated'); - output.hint('Run "vizzly login" to authenticate first'); - process.exit(1); - } - - let client = createAuthClient({ - baseUrl: options.apiUrl || getApiUrl(), - }); - let tokenStore = createTokenStore(); - - // Get user info to show organizations - output.startSpinner('Fetching organizations...'); - let userInfo = await whoami(client, tokenStore); - output.stopSpinner(); - - if (!userInfo.organizations || userInfo.organizations.length === 0) { - output.error('No organizations found'); - output.hint('Create an organization at https://vizzly.dev'); - process.exit(1); - } - - // Select organization - output.labelValue('Organizations', ''); - userInfo.organizations.forEach((org, index) => { - output.print(` ${index + 1}. ${org.name} (@${org.slug})`); - }); - - output.blank(); - let orgChoice = await promptNumber( - 'Enter number', - 1, - userInfo.organizations.length - ); - let selectedOrg = userInfo.organizations[orgChoice - 1]; - - // List projects for organization - output.startSpinner(`Fetching projects for ${selectedOrg.name}...`); - - let response = await makeAuthenticatedRequest( - `${options.apiUrl || getApiUrl()}/api/project`, - { - headers: { - Authorization: `Bearer ${auth.accessToken}`, - 'X-Organization': selectedOrg.slug, - }, - } - ); - - output.stopSpinner(); - - // Handle both array response and object with projects property - let projects = Array.isArray(response) ? response : response.projects || []; - - if (projects.length === 0) { - output.error('No projects found'); - output.hint( - `Create a project in ${selectedOrg.name} at https://vizzly.dev` - ); - process.exit(1); - } - - // Select project - output.blank(); - output.labelValue('Projects', ''); - projects.forEach((project, index) => { - output.print(` ${index + 1}. ${project.name} (${project.slug})`); - }); - - output.blank(); - let projectChoice = await promptNumber('Enter number', 1, projects.length); - let selectedProject = projects[projectChoice - 1]; - - // Create API token for project - output.startSpinner(`Creating API token for ${selectedProject.name}...`); - - let tokenResponse = await makeAuthenticatedRequest( - `${options.apiUrl || getApiUrl()}/api/project/${selectedProject.slug}/tokens`, - { - method: 'POST', - headers: { - Authorization: `Bearer ${auth.accessToken}`, - 'X-Organization': selectedOrg.slug, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - name: `CLI Token - ${new Date().toLocaleDateString()}`, - description: `Generated by vizzly CLI for ${process.cwd()}`, - }), - } - ); - - output.stopSpinner(); - - // Save project mapping - let currentDir = resolve(process.cwd()); - await saveProjectMapping(currentDir, { - token: tokenResponse.token, - projectSlug: selectedProject.slug, - projectName: selectedProject.name, - organizationSlug: selectedOrg.slug, - }); - - // JSON output for success - if (globalOptions.json) { - output.data({ - status: 'configured', - project: { - name: selectedProject.name, - slug: selectedProject.slug, - }, - organization: { - name: selectedOrg.name, - slug: selectedOrg.slug, - }, - directory: currentDir, - tokenCreated: true, - }); - output.cleanup(); - return; - } - - output.complete('Project configured'); - output.blank(); - output.keyValue({ - Project: selectedProject.name, - Organization: selectedOrg.name, - Directory: currentDir, - }); - - output.cleanup(); - } catch (error) { - output.stopSpinner(); - output.error('Failed to configure project', error); - process.exit(1); - } -} - -/** - * Project list command - show all configured projects - * @param {Object} _options - Command options (unused) - * @param {Object} globalOptions - Global CLI options - */ -export async function projectListCommand(_options = {}, globalOptions = {}) { - output.configure({ - json: globalOptions.json, - verbose: globalOptions.verbose, - color: !globalOptions.noColor, - }); - - try { - let mappings = await getProjectMappings(); - let paths = Object.keys(mappings); - - let currentDir = resolve(process.cwd()); - - if (paths.length === 0) { - if (globalOptions.json) { - output.data({ projects: [], current: null }); - } else { - output.header('project:list'); - output.print(' No projects configured'); - output.blank(); - output.hint('Run "vizzly project:select" to configure a project'); - } - output.cleanup(); - return; - } - - if (globalOptions.json) { - let projects = paths.map(path => { - let mapping = mappings[path]; - return { - directory: path, - isCurrent: path === currentDir, - project: { - name: mapping.projectName, - slug: mapping.projectSlug, - }, - organization: mapping.organizationSlug, - createdAt: mapping.createdAt, - }; - }); - let current = projects.find(p => p.isCurrent) || null; - output.data({ projects, current }); - output.cleanup(); - return; - } - - output.header('project:list'); - - let colors = output.getColors(); - - for (let path of paths) { - let mapping = mappings[path]; - let isCurrent = path === currentDir; - let marker = isCurrent ? colors.brand.amber('→') : ' '; - - output.print(`${marker} ${isCurrent ? colors.bold(path) : path}`); - output.keyValue( - { - Project: `${mapping.projectName} (${mapping.projectSlug})`, - Org: mapping.organizationSlug, - }, - { indent: 4 } - ); - - if (globalOptions.verbose) { - // Extract token string (handle both string and object formats) - let tokenStr = - typeof mapping.token === 'string' - ? mapping.token - : mapping.token?.token || '[invalid token]'; - - output.hint(`Token: ${tokenStr.substring(0, 20)}...`, { indent: 4 }); - output.hint( - `Created: ${new Date(mapping.createdAt).toLocaleString()}`, - { indent: 4 } - ); - } - output.blank(); - } - - output.cleanup(); - } catch (error) { - output.error('Failed to list projects', error); - process.exit(1); - } -} - -/** - * Project token command - show/regenerate token for current directory - * @param {Object} _options - Command options (unused) - * @param {Object} globalOptions - Global CLI options - */ -export async function projectTokenCommand(_options = {}, globalOptions = {}) { - output.configure({ - json: globalOptions.json, - verbose: globalOptions.verbose, - color: !globalOptions.noColor, - }); - - try { - let currentDir = resolve(process.cwd()); - let mapping = await getProjectMapping(currentDir); - - if (!mapping) { - output.error('No project configured for this directory'); - output.hint('Run "vizzly project:select" to configure a project'); - process.exit(1); - } - - // Extract token string (handle both string and object formats) - let tokenStr = - typeof mapping.token === 'string' - ? mapping.token - : mapping.token?.token || '[invalid token]'; - - if (globalOptions.json) { - output.data({ - token: tokenStr, - directory: currentDir, - project: { - name: mapping.projectName, - slug: mapping.projectSlug, - }, - organization: mapping.organizationSlug, - createdAt: mapping.createdAt, - }); - output.cleanup(); - return; - } - - output.header('project:token'); - output.printBox(tokenStr, { title: 'Token' }); - output.blank(); - output.keyValue({ - Project: `${mapping.projectName} (${mapping.projectSlug})`, - Org: mapping.organizationSlug, - }); - - output.cleanup(); - } catch (error) { - output.error('Failed to get project token', error); - process.exit(1); - } -} - -/** - * Helper to make authenticated API request - */ -async function makeAuthenticatedRequest(url, options = {}) { - const response = await fetch(url, options); - - if (!response.ok) { - let errorText = ''; - try { - const errorData = await response.json(); - errorText = errorData.error || errorData.message || ''; - } catch { - errorText = await response.text(); - } - throw new Error( - `API request failed: ${response.status}${errorText ? ` - ${errorText}` : ''}` - ); - } - - return response.json(); -} - -/** - * Helper to prompt for a number - */ -function promptNumber(message, min, max) { - return new Promise(resolve => { - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, - }); - - const ask = () => { - rl.question(`${message} (${min}-${max}): `, answer => { - const num = parseInt(answer, 10); - if (Number.isNaN(num) || num < min || num > max) { - output.print(`Please enter a number between ${min} and ${max}`); - ask(); - } else { - rl.close(); - resolve(num); - } - }); - }; - - ask(); - }); -} - -/** - * Project remove command - remove project configuration for current directory - * @param {Object} _options - Command options (unused) - * @param {Object} globalOptions - Global CLI options - */ -export async function projectRemoveCommand(_options = {}, globalOptions = {}) { - output.configure({ - json: globalOptions.json, - verbose: globalOptions.verbose, - color: !globalOptions.noColor, - }); - - try { - let currentDir = resolve(process.cwd()); - let mapping = await getProjectMapping(currentDir); - - if (!mapping) { - if (globalOptions.json) { - output.data({ removed: false, reason: 'not_configured' }); - } else { - output.header('project:remove'); - output.print(' No project configured for this directory'); - } - output.cleanup(); - return; - } - - // In JSON mode, skip confirmation (for scripting) - if (globalOptions.json) { - await deleteProjectMapping(currentDir); - output.data({ - removed: true, - directory: currentDir, - project: { - name: mapping.projectName, - slug: mapping.projectSlug, - }, - organization: mapping.organizationSlug, - }); - output.cleanup(); - return; - } - - // Confirm removal (interactive mode only) - output.header('project:remove'); - output.labelValue('Current configuration', ''); - output.keyValue({ - Project: `${mapping.projectName} (${mapping.projectSlug})`, - Org: mapping.organizationSlug, - Directory: currentDir, - }); - output.blank(); - - let confirmed = await promptConfirm('Remove this project configuration?'); - - if (!confirmed) { - output.print(' Cancelled'); - output.cleanup(); - return; - } - - await deleteProjectMapping(currentDir); - - output.complete('Project configuration removed'); - output.blank(); - output.hint('Run "vizzly project:select" to configure a different project'); - - output.cleanup(); - } catch (error) { - output.error('Failed to remove project configuration', error); - process.exit(1); - } -} - -/** - * Helper to prompt for confirmation - */ -function promptConfirm(message) { - return new Promise(resolve => { - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, - }); - - rl.question(`${message} (y/n): `, answer => { - rl.close(); - resolve(answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes'); - }); - }); -} - -/** - * Validate project command options - */ -export function validateProjectOptions() { - return []; -} diff --git a/src/project/core.js b/src/project/core.js index 913c28c6..d14fd118 100644 --- a/src/project/core.js +++ b/src/project/core.js @@ -6,96 +6,6 @@ import { VizzlyError } from '../errors/vizzly-error.js'; -// ============================================================================ -// Validation -// ============================================================================ - -/** - * Validate that a directory path is provided - * @param {string} directory - Directory path to validate - * @returns {{ valid: boolean, error: Error|null }} - */ -export function validateDirectory(directory) { - if (!directory) { - return { - valid: false, - error: new VizzlyError('Directory path is required', 'INVALID_DIRECTORY'), - }; - } - return { valid: true, error: null }; -} - -/** - * Validate project data for mapping creation - * @param {Object} projectData - Project data to validate - * @param {string} [projectData.projectSlug] - Project slug - * @param {string} [projectData.organizationSlug] - Organization slug - * @param {string} [projectData.token] - Project token - * @returns {{ valid: boolean, error: Error|null }} - */ -export function validateProjectData(projectData) { - if (!projectData.projectSlug) { - return { - valid: false, - error: new VizzlyError( - 'Project slug is required', - 'INVALID_PROJECT_DATA' - ), - }; - } - - if (!projectData.organizationSlug) { - return { - valid: false, - error: new VizzlyError( - 'Organization slug is required', - 'INVALID_PROJECT_DATA' - ), - }; - } - - if (!projectData.token) { - return { - valid: false, - error: new VizzlyError( - 'Project token is required', - 'INVALID_PROJECT_DATA' - ), - }; - } - - return { valid: true, error: null }; -} - -// ============================================================================ -// Mapping Transformations -// ============================================================================ - -/** - * Convert mappings object to array with directory paths included - * @param {Object} mappings - Object with directory paths as keys - * @returns {Array} Array of mappings with directory property - */ -export function mappingsToArray(mappings) { - return Object.entries(mappings).map(([directory, data]) => ({ - directory, - ...data, - })); -} - -/** - * Build a mapping result object - * @param {string} directory - Directory path - * @param {Object} projectData - Project data - * @returns {Object} Mapping result with directory included - */ -export function buildMappingResult(directory, projectData) { - return { - directory, - ...projectData, - }; -} - // ============================================================================ // API Request Helpers // ============================================================================ diff --git a/src/project/operations.js b/src/project/operations.js index f994a32b..d35afc79 100644 --- a/src/project/operations.js +++ b/src/project/operations.js @@ -2,7 +2,6 @@ * Project Operations - Project operations with dependency injection * * Each operation takes its dependencies as parameters: - * - mappingStore: for reading/writing project mappings * - httpClient: for making API requests (OAuth or API token based) * * This makes them trivially testable without mocking modules. @@ -10,7 +9,6 @@ import { buildBuildsUrl, - buildMappingResult, buildNoApiServiceError, buildNoAuthError, buildOrgHeader, @@ -27,95 +25,8 @@ import { extractProjects, extractToken, extractTokens, - mappingsToArray, - validateDirectory, - validateProjectData, } from './core.js'; -// ============================================================================ -// Mapping Operations -// ============================================================================ - -/** - * List all project mappings - * @param {Object} mappingStore - Store with getMappings method - * @returns {Promise} Array of project mappings with directory included - */ -export async function listMappings(mappingStore) { - let mappings = await mappingStore.getMappings(); - return mappingsToArray(mappings); -} - -/** - * Get project mapping for a specific directory - * @param {Object} mappingStore - Store with getMapping method - * @param {string} directory - Directory path - * @returns {Promise} Project mapping or null - */ -export async function getMapping(mappingStore, directory) { - return mappingStore.getMapping(directory); -} - -/** - * Create or update project mapping - * @param {Object} mappingStore - Store with saveMapping method - * @param {string} directory - Directory path - * @param {Object} projectData - Project data - * @returns {Promise} Created mapping with directory included - */ -export async function createMapping(mappingStore, directory, projectData) { - let dirValidation = validateDirectory(directory); - if (!dirValidation.valid) { - throw dirValidation.error; - } - - let dataValidation = validateProjectData(projectData); - if (!dataValidation.valid) { - throw dataValidation.error; - } - - await mappingStore.saveMapping(directory, projectData); - return buildMappingResult(directory, projectData); -} - -/** - * Remove project mapping - * @param {Object} mappingStore - Store with deleteMapping method - * @param {string} directory - Directory path - * @returns {Promise} - */ -export async function removeMapping(mappingStore, directory) { - let validation = validateDirectory(directory); - if (!validation.valid) { - throw validation.error; - } - - await mappingStore.deleteMapping(directory); -} - -/** - * Switch project for a directory (convenience wrapper for createMapping) - * @param {Object} mappingStore - Store with saveMapping method - * @param {string} directory - Directory path - * @param {string} projectSlug - Project slug - * @param {string} organizationSlug - Organization slug - * @param {string} token - Project token - * @returns {Promise} Updated mapping - */ -export async function switchProject( - mappingStore, - directory, - projectSlug, - organizationSlug, - token -) { - return createMapping(mappingStore, directory, { - projectSlug, - organizationSlug, - token, - }); -} - // ============================================================================ // API Operations - List Projects // ============================================================================ diff --git a/src/reporter/src/api/client.js b/src/reporter/src/api/client.js index db9f0bff..4995d6e2 100644 --- a/src/reporter/src/api/client.js +++ b/src/reporter/src/api/client.js @@ -272,7 +272,7 @@ export const auth = { }; /** - * Projects API - Project mappings (local storage) + * Projects API - Project listing and builds */ export const projects = { /** @@ -283,54 +283,6 @@ export const projects = { return fetchJson('/api/projects'); }, - /** - * List project directory mappings - * @returns {Promise} - */ - async listMappings() { - return fetchJson('/api/projects/mappings'); - }, - - /** - * Create or update project mapping - * @param {Object} data - * @returns {Promise} - */ - async createMapping(data) { - return fetchJson('/api/projects/mappings', { - method: 'POST', - body: JSON.stringify(data), - }); - }, - - /** - * Delete project mapping - * @param {string} directory - * @returns {Promise} - */ - async deleteMapping(directory) { - return fetchJson( - `/api/projects/mappings/${encodeURIComponent(directory)}`, - { - method: 'DELETE', - } - ); - }, - - /** - * Get recent builds for current project - * @param {Object} options - * @returns {Promise} - */ - async getRecentBuilds(options = {}) { - const params = new URLSearchParams(); - if (options.limit) params.append('limit', String(options.limit)); - if (options.branch) params.append('branch', options.branch); - - const query = params.toString(); - return fetchJson(`/api/builds/recent${query ? `?${query}` : ''}`); - }, - /** * Get builds for a specific project * @param {string} organizationSlug diff --git a/src/reporter/src/components/app-router.jsx b/src/reporter/src/components/app-router.jsx index a3a9d171..e17358a8 100644 --- a/src/reporter/src/components/app-router.jsx +++ b/src/reporter/src/components/app-router.jsx @@ -6,7 +6,6 @@ import { Layout } from './layout/index.js'; import BuildsView from './views/builds-view.jsx'; import ComparisonDetailView from './views/comparison-detail-view.jsx'; import ComparisonsView from './views/comparisons-view.jsx'; -import ProjectsView from './views/projects-view.jsx'; import SettingsView from './views/settings-view.jsx'; import StatsView from './views/stats-view.jsx'; import WaitingForScreenshots from './waiting-for-screenshots.jsx'; @@ -54,25 +53,19 @@ export default function AppRouter() { ? 'stats' : location === '/settings' ? 'settings' - : location === '/projects' - ? 'projects' - : location === '/builds' - ? 'builds' - : 'comparisons'; + : location === '/builds' + ? 'builds' + : 'comparisons'; const navigateTo = view => { if (view === 'stats') setLocation('/stats'); else if (view === 'settings') setLocation('/settings'); - else if (view === 'projects') setLocation('/projects'); else if (view === 'builds') setLocation('/builds'); else setLocation('/'); }; // Settings, Projects, and Builds don't need screenshot data - always allow access - const isManagementRoute = - location === '/settings' || - location === '/projects' || - location === '/builds'; + const isManagementRoute = location === '/settings' || location === '/builds'; // Loading state (but not for management routes) if (isLoading && !reportData && !isManagementRoute) { @@ -153,10 +146,6 @@ export default function AppRouter() { - - - - diff --git a/src/reporter/src/components/layout/header.jsx b/src/reporter/src/components/layout/header.jsx index 5b30d3d4..72576378 100644 --- a/src/reporter/src/components/layout/header.jsx +++ b/src/reporter/src/components/layout/header.jsx @@ -10,7 +10,6 @@ import { ChartBarIcon, CloudIcon, Cog6ToothIcon, - FolderIcon, PhotoIcon, XMarkIcon, } from '@heroicons/react/24/outline'; @@ -21,7 +20,6 @@ const navItems = [ { key: 'comparisons', label: 'Comparisons', icon: PhotoIcon }, { key: 'stats', label: 'Stats', icon: ChartBarIcon }, { key: 'builds', label: 'Builds', icon: CloudIcon }, - { key: 'projects', label: 'Projects', icon: FolderIcon }, { key: 'settings', label: 'Settings', icon: Cog6ToothIcon }, ]; diff --git a/src/reporter/src/components/views/builds-view.jsx b/src/reporter/src/components/views/builds-view.jsx index 28e4d48c..ae353211 100644 --- a/src/reporter/src/components/views/builds-view.jsx +++ b/src/reporter/src/components/views/builds-view.jsx @@ -11,14 +11,21 @@ import { UserCircleIcon, XCircleIcon, } from '@heroicons/react/24/outline'; -import { useCallback, useMemo, useState } from 'react'; -import { useAuthStatus } from '../../hooks/queries/use-auth-queries.js'; +import { useQueryClient } from '@tanstack/react-query'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { + useAuthStatus, + useInitiateLogin, + usePollAuthorization, +} from '../../hooks/queries/use-auth-queries.js'; import { useBuilds, useDownloadBaselines, useProjects, } from '../../hooks/queries/use-cloud-queries.js'; +import { queryKeys } from '../../lib/query-keys.js'; import { + Alert, Badge, Button, Card, @@ -217,7 +224,143 @@ function OrganizationSection({ ); } -function LoginPrompt({ onLogin }) { +function DeviceFlowLogin({ onComplete }) { + const [deviceFlow, setDeviceFlow] = useState(null); + const [error, setError] = useState(null); + + const initiateLoginMutation = useInitiateLogin(); + const pollMutation = usePollAuthorization(); + const { addToast } = useToast(); + + useEffect(() => { + async function startDeviceFlow() { + try { + const flow = await initiateLoginMutation.mutateAsync(); + setDeviceFlow(flow); + } catch (err) { + setError(err.message); + addToast(`Failed to start login: ${err.message}`, 'error'); + } + } + + startDeviceFlow(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [addToast, initiateLoginMutation.mutateAsync]); + + async function checkAuthorization() { + if (!deviceFlow?.deviceCode) return; + + setError(null); + + try { + const result = await pollMutation.mutateAsync(deviceFlow.deviceCode); + + if (result.status === 'complete') { + addToast('Login successful!', 'success'); + onComplete?.(); + } else if (result.status === 'pending') { + addToast('Still waiting for authorization...', 'info'); + } else { + setError('Unexpected response from server'); + } + } catch (err) { + setError(err.message); + addToast(`Check failed: ${err.message}`, 'error'); + } + } + + if (error) { + return ( + + {error} + + ); + } + + if (!deviceFlow) { + return ( +
+ +

Starting login flow...

+
+ ); + } + + return ( + + +

+ Sign in to Vizzly +

+ +
+

+ Click below to authorize: +

+ + Open Authorization Page + +
+

+ Or enter this code manually: +

+
+ {deviceFlow.userCode} +
+
+
+ +

+ After authorizing in your browser, click the button below to complete + sign in. +

+ + +
+
+ ); +} + +function LoginPrompt() { + const [showingLogin, setShowingLogin] = useState(false); + const queryClient = useQueryClient(); + const { addToast } = useToast(); + + const handleLoginComplete = useCallback(() => { + setShowingLogin(false); + queryClient.invalidateQueries({ queryKey: queryKeys.auth }); + queryClient.invalidateQueries({ queryKey: queryKeys.projects() }); + addToast('Login successful!', 'success'); + }, [queryClient, addToast]); + + if (showingLogin) { + return ( +
+ + +
+ ); + } + return ( @@ -233,7 +376,7 @@ function LoginPrompt({ onLogin }) {

-
-
- ); -} - -function AuthCard() { - const [showingLogin, setShowingLogin] = useState(false); - const queryClient = useQueryClient(); - const { data: authData, isLoading } = useAuthStatus(); - const logoutMutation = useLogout(); - const { addToast } = useToast(); - - const user = authData?.user; - const authenticated = authData?.authenticated; - - const handleLogout = useCallback(async () => { - try { - await logoutMutation.mutateAsync(); - addToast('Logged out successfully', 'success'); - } catch (err) { - addToast(`Logout failed: ${err.message}`, 'error'); - } - }, [logoutMutation, addToast]); - - const handleLoginComplete = useCallback(() => { - setShowingLogin(false); - queryClient.invalidateQueries({ queryKey: queryKeys.auth }); - }, [queryClient]); - - if (isLoading) { - return ; - } - - if (showingLogin) { - return ( -
- - -
- ); - } - - if (!authenticated) { - return ( - - -
- -
-

- Not signed in -

-

- Sign in to access projects and team features -

- -
-
- ); - } - - return ( - - -
-
-
- -
-
-

- {user?.name || 'User'} -

-

{user?.email}

- {user?.organizationName && ( - - {user.organizationName} - - )} -
-
- -
-
-
- ); -} - -function ProjectMappingsTable({ mappings, onDelete, deleting }) { - const { addToast, confirm } = useToast(); - - const handleDelete = useCallback( - async directory => { - const confirmed = await confirm( - `Remove project mapping for ${directory}?`, - 'This will not delete any files, only the project association.' - ); - - if (!confirmed) return; - - try { - await onDelete(directory); - addToast('Mapping removed successfully', 'success'); - } catch (err) { - addToast(`Failed to remove mapping: ${err.message}`, 'error'); - } - }, - [onDelete, addToast, confirm] - ); - - if (mappings.length === 0) { - return ( - - - vizzly project:select - - - } - /> - ); - } - - return ( -
- - - - - - - - - - - {mappings.map(mapping => ( - - - - - - - ))} - -
DirectoryProjectOrganizationActions
{mapping.directory} - {mapping.projectName || mapping.projectSlug} - {mapping.organizationSlug} -
-
- ); -} - -export default function ProjectsView() { - const { - data: mappingsData, - isLoading: mappingsLoading, - refetch, - } = useProjectMappings(); - const deleteMappingMutation = useDeleteProjectMapping(); - - const mappings = mappingsData?.mappings || []; - - const handleDeleteMapping = useCallback( - async directory => { - await deleteMappingMutation.mutateAsync(directory); - }, - [deleteMappingMutation] - ); - - if (mappingsLoading) { - return ( -
-
- - -
-
-
- -
- -
- -
- ); - } - - return ( -
- {/* Page Header */} -
-

Projects

-

- Manage your Vizzly account and directory mappings -

-
- - {/* Auth + Quick Stats */} -
-
- -
- - - -
- Project Mappings - - {mappings.length} - -
-
-
-
- - {/* Project Mappings */} - - refetch()} - icon={ArrowPathIcon} - > - Refresh - - } - /> - - - - -
- ); -} diff --git a/src/reporter/src/hooks/queries/use-cloud-queries.js b/src/reporter/src/hooks/queries/use-cloud-queries.js index cbe723df..479435a8 100644 --- a/src/reporter/src/hooks/queries/use-cloud-queries.js +++ b/src/reporter/src/hooks/queries/use-cloud-queries.js @@ -32,32 +32,3 @@ export function useDownloadBaselines() { }, }); } - -export function useProjectMappings(options = {}) { - return useQuery({ - queryKey: [...queryKeys.projects(), 'mappings'], - queryFn: projects.listMappings, - staleTime: 60 * 1000, - ...options, - }); -} - -export function useCreateProjectMapping() { - const queryClient = useQueryClient(); - return useMutation({ - mutationFn: projects.createMapping, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: queryKeys.projects() }); - }, - }); -} - -export function useDeleteProjectMapping() { - const queryClient = useQueryClient(); - return useMutation({ - mutationFn: directory => projects.deleteMapping(directory), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: queryKeys.projects() }); - }, - }); -} diff --git a/src/server/routers/projects.js b/src/server/routers/projects.js index c2688b6b..c99cbf19 100644 --- a/src/server/routers/projects.js +++ b/src/server/routers/projects.js @@ -1,10 +1,9 @@ /** * Projects Router - * Handles project management and builds endpoints + * Handles project listing and builds endpoints */ import * as output from '../../utils/output.js'; -import { parseJsonBody } from '../middleware/json-parser.js'; import { sendError, sendServiceUnavailable, @@ -38,101 +37,6 @@ export function createProjectsRouter({ projectService }) { } } - // List project directory mappings - if (req.method === 'GET' && pathname === '/api/projects/mappings') { - try { - const mappings = await projectService.listMappings(); - sendSuccess(res, { mappings }); - return true; - } catch (error) { - output.debug('Error listing project mappings:', { - error: error.message, - }); - sendError(res, 500, error.message); - return true; - } - } - - // Create or update project mapping - if (req.method === 'POST' && pathname === '/api/projects/mappings') { - try { - const body = await parseJsonBody(req); - const { directory, projectSlug, organizationSlug, token, projectName } = - body; - - const mapping = await projectService.createMapping(directory, { - projectSlug, - organizationSlug, - token, - projectName, - }); - - sendSuccess(res, { success: true, mapping }); - return true; - } catch (error) { - output.debug('Error creating project mapping:', { - error: error.message, - }); - sendError(res, 500, error.message); - return true; - } - } - - // Delete project mapping - if ( - req.method === 'DELETE' && - pathname.startsWith('/api/projects/mappings/') - ) { - try { - const directory = decodeURIComponent( - pathname.replace('/api/projects/mappings/', '') - ); - await projectService.removeMapping(directory); - sendSuccess(res, { success: true, message: 'Mapping deleted' }); - return true; - } catch (error) { - output.debug('Error deleting project mapping:', { - error: error.message, - }); - sendError(res, 500, error.message); - return true; - } - } - - // Get recent builds for current project - if (req.method === 'GET' && pathname === '/api/builds/recent') { - if (!projectService) { - sendServiceUnavailable(res, 'Project service'); - return true; - } - - try { - const currentDir = process.cwd(); - const mapping = await projectService.getMapping(currentDir); - - if (!mapping || !mapping.projectSlug || !mapping.organizationSlug) { - sendError(res, 400, 'No project configured for this directory'); - return true; - } - - const limit = parseInt(parsedUrl.searchParams.get('limit') || '10', 10); - const branch = parsedUrl.searchParams.get('branch') || undefined; - - const builds = await projectService.getRecentBuilds( - mapping.projectSlug, - mapping.organizationSlug, - { limit, branch } - ); - - sendSuccess(res, { builds }); - return true; - } catch (error) { - output.debug('Error fetching recent builds:', { error: error.message }); - sendError(res, 500, error.message); - return true; - } - } - // Get builds for a specific project (used by /builds page) const projectBuildsMatch = pathname.match( /^\/api\/projects\/([^/]+)\/([^/]+)\/builds$/ diff --git a/src/services/project-service.js b/src/services/project-service.js index 89af86f7..5d978195 100644 --- a/src/services/project-service.js +++ b/src/services/project-service.js @@ -4,35 +4,22 @@ * * Provides the interface expected by src/server/routers/projects.js: * - listProjects() - Returns [] if not authenticated - * - listMappings() - Returns [] if no mappings - * - getMapping(directory) - Returns null if not found - * - createMapping(directory, projectData) - Throws on invalid input - * - removeMapping(directory) - Throws on invalid directory * - getRecentBuilds(projectSlug, organizationSlug, options) - Returns [] if not authenticated * * Error handling: * - API methods (listProjects, getRecentBuilds) return empty arrays when not authenticated - * - Local methods (listMappings, getMapping) never require authentication - * - Validation errors (createMapping, removeMapping) throw with descriptive messages */ import { createAuthClient } from '../auth/client.js'; import * as projectOps from '../project/operations.js'; import { getApiUrl } from '../utils/environment-config.js'; -import { - deleteProjectMapping, - getAuthTokens, - getProjectMapping, - getProjectMappings, - saveProjectMapping, -} from '../utils/global-config.js'; +import { getAuthTokens } from '../utils/global-config.js'; /** * Create a project service instance * @param {Object} [options] * @param {string} [options.apiUrl] - API base URL (defaults to VIZZLY_API_URL or https://app.vizzly.dev) * @param {Object} [options.httpClient] - Injectable HTTP client (for testing) - * @param {Object} [options.mappingStore] - Injectable mapping store (for testing) * @param {Function} [options.getAuthTokens] - Injectable token getter (for testing) * @returns {Object} Project service */ @@ -43,15 +30,6 @@ export function createProjectService(options = {}) { // Allow injection for testing let httpClient = options.httpClient || createAuthClient({ baseUrl: apiUrl }); - // Create mapping store adapter for global config - // Allow injection for testing - let mappingStore = options.mappingStore || { - getMappings: getProjectMappings, - getMapping: getProjectMapping, - saveMapping: saveProjectMapping, - deleteMapping: deleteProjectMapping, - }; - // Allow injection of getAuthTokens for testing let tokenGetter = options.getAuthTokens || getAuthTokens; @@ -88,42 +66,6 @@ export function createProjectService(options = {}) { return projectOps.listProjects({ oauthClient, apiClient: null }); }, - /** - * List all project mappings - * @returns {Promise} Array of project mappings - */ - async listMappings() { - return projectOps.listMappings(mappingStore); - }, - - /** - * Get project mapping for a specific directory - * @param {string} directory - Directory path - * @returns {Promise} Project mapping or null - */ - async getMapping(directory) { - return projectOps.getMapping(mappingStore, directory); - }, - - /** - * Create or update project mapping - * @param {string} directory - Directory path - * @param {Object} projectData - Project data - * @returns {Promise} Created mapping - */ - async createMapping(directory, projectData) { - return projectOps.createMapping(mappingStore, directory, projectData); - }, - - /** - * Remove project mapping - * @param {string} directory - Directory path - * @returns {Promise} - */ - async removeMapping(directory) { - return projectOps.removeMapping(mappingStore, directory); - }, - /** * Get recent builds for a project * Returns empty array if not authenticated (projectOps handles null oauthClient) diff --git a/src/utils/config-loader.js b/src/utils/config-loader.js index a72bc4e1..211c4f1f 100644 --- a/src/utils/config-loader.js +++ b/src/utils/config-loader.js @@ -7,7 +7,7 @@ import { getBuildName, getParallelId, } from './environment-config.js'; -import { getAccessToken, getProjectMapping } from './global-config.js'; +import { getAccessToken } from './global-config.js'; import * as output from './output.js'; const DEFAULT_CONFIG = { @@ -76,38 +76,7 @@ export async function loadConfig(configPath = null, cliOverrides = {}) { // Merge validated file config mergeConfig(config, validatedFileConfig); - // 3. Check project mapping for current directory (if no CLI flag) - if (!cliOverrides.token) { - const currentDir = process.cwd(); - - const projectMapping = await getProjectMapping(currentDir); - if (projectMapping?.token) { - // Handle both string tokens and token objects (backward compatibility) - let token; - if (typeof projectMapping.token === 'string') { - token = projectMapping.token; - } else if ( - typeof projectMapping.token === 'object' && - projectMapping.token.token - ) { - // Handle nested token object from old API responses - token = projectMapping.token.token; - } else { - token = String(projectMapping.token); - } - - config.apiKey = token; - config.projectSlug = projectMapping.projectSlug; - config.organizationSlug = projectMapping.organizationSlug; - - output.debug( - 'config', - `linked to ${projectMapping.projectSlug} (${projectMapping.organizationSlug})` - ); - } - } - - // 4. Override with environment variables (higher priority than fallbacks) + // 3. Override with environment variables (higher priority than fallbacks) const envApiKey = getApiToken(); const envApiUrl = getApiUrl(); const envBuildName = getBuildName(); @@ -124,21 +93,20 @@ export async function loadConfig(configPath = null, cliOverrides = {}) { } if (envParallelId) config.parallelId = envParallelId; - // 5. Apply CLI overrides (highest priority) + // 4. Apply CLI overrides (highest priority) if (cliOverrides.token) { output.debug('config', 'using token from --token flag'); } applyCLIOverrides(config, cliOverrides); - // 6. Fall back to user auth token if no other token found + // 5. Fall back to user auth token if no other token found // This enables interactive commands (builds, comparisons, approve, etc.) // to work without a project token when the user is logged in if (!config.apiKey) { let userToken = await getAccessToken(); if (userToken) { config.apiKey = userToken; - config.isUserAuth = true; // Flag to indicate this is user auth, not project token output.debug('config', 'using token from user login'); } } diff --git a/src/utils/context.js b/src/utils/context.js index 0baa6712..7d665b21 100644 --- a/src/utils/context.js +++ b/src/utils/context.js @@ -10,7 +10,7 @@ import { existsSync, readFileSync } from 'node:fs'; import { homedir } from 'node:os'; -import { dirname, join } from 'node:path'; +import { join } from 'node:path'; /** * Get dynamic context about the current Vizzly state @@ -67,20 +67,6 @@ export function getContext() { // Ignore } - // Check for project mapping (from vizzly project:select) - // Traverse up to find project config, with bounds check for Windows compatibility - let projectMapping = null; - let checkPath = cwd; - let prevPath = null; - while (checkPath && checkPath !== prevPath) { - if (globalConfig.projects?.[checkPath]) { - projectMapping = globalConfig.projects[checkPath]; - break; - } - prevPath = checkPath; - checkPath = dirname(checkPath); - } - // Check for OAuth login (from vizzly login) let isLoggedIn = !!globalConfig.auth?.accessToken; let userName = @@ -98,13 +84,7 @@ export function getContext() { }); } - if (projectMapping) { - items.push({ - type: 'success', - label: 'Project', - value: `${projectMapping.projectName} (${projectMapping.organizationSlug})`, - }); - } else if (isLoggedIn && userName) { + if (isLoggedIn && userName) { items.push({ type: 'success', label: 'Logged in', value: userName }); } else if (hasEnvToken) { items.push({ @@ -116,7 +96,7 @@ export function getContext() { items.push({ type: 'info', label: 'Not connected', - value: 'run vizzly login or project:select', + value: 'run vizzly login', }); } @@ -163,7 +143,6 @@ export function getDetailedContext() { }, project: { hasConfig: false, - mapping: null, }, auth: { loggedIn: false, @@ -214,19 +193,6 @@ export function getDetailedContext() { // Ignore } - // Check for project mapping - // Traverse up to find project config, with bounds check for Windows compatibility - let checkPath = cwd; - let prevPath = null; - while (checkPath && checkPath !== prevPath) { - if (globalConfig.projects?.[checkPath]) { - context.project.mapping = globalConfig.projects[checkPath]; - break; - } - prevPath = checkPath; - checkPath = dirname(checkPath); - } - // Check auth status context.auth.loggedIn = !!globalConfig.auth?.accessToken; context.auth.userName = diff --git a/src/utils/global-config.js b/src/utils/global-config.js index 87a5b027..5dabe585 100644 --- a/src/utils/global-config.js +++ b/src/utils/global-config.js @@ -6,7 +6,7 @@ import { existsSync } from 'node:fs'; import { chmod, mkdir, readFile, writeFile } from 'node:fs/promises'; import { homedir } from 'node:os'; -import { dirname, join, parse } from 'node:path'; +import { join } from 'node:path'; import * as output from './output.js'; /** @@ -205,78 +205,3 @@ export async function getAccessToken() { let auth = await getAuthTokens(); return auth?.accessToken || null; } - -/** - * Get project mapping for a directory - * Walks up the directory tree to find the closest mapping - * @param {string} directoryPath - Absolute path to project directory - * @returns {Promise} Project data or null - */ -export async function getProjectMapping(directoryPath) { - const config = await loadGlobalConfig(); - if (!config.projects) { - return null; - } - - // Walk up the directory tree looking for a mapping - let currentPath = directoryPath; - const { root } = parse(currentPath); - - while (currentPath !== root) { - if (config.projects[currentPath]) { - return config.projects[currentPath]; - } - - // Move to parent directory - const parentPath = dirname(currentPath); - if (parentPath === currentPath) { - // We've reached the root - break; - } - currentPath = parentPath; - } - - return null; -} - -/** - * Save project mapping for a directory - * @param {string} directoryPath - Absolute path to project directory - * @param {Object} projectData - Project configuration - * @param {string} projectData.token - Project API token (vzt_...) - * @param {string} projectData.projectSlug - Project slug - * @param {string} projectData.organizationSlug - Organization slug - * @param {string} projectData.projectName - Project name - */ -export async function saveProjectMapping(directoryPath, projectData) { - const config = await loadGlobalConfig(); - if (!config.projects) { - config.projects = {}; - } - config.projects[directoryPath] = { - ...projectData, - createdAt: new Date().toISOString(), - }; - await saveGlobalConfig(config); -} - -/** - * Get all project mappings - * @returns {Promise} Map of directory paths to project data - */ -export async function getProjectMappings() { - const config = await loadGlobalConfig(); - return config.projects || {}; -} - -/** - * Delete project mapping for a directory - * @param {string} directoryPath - Absolute path to project directory - */ -export async function deleteProjectMapping(directoryPath) { - const config = await loadGlobalConfig(); - if (config.projects?.[directoryPath]) { - delete config.projects[directoryPath]; - await saveGlobalConfig(config); - } -} diff --git a/tests/commands/builds.test.js b/tests/commands/builds.test.js index 3dc697bf..6d6bf1b1 100644 --- a/tests/commands/builds.test.js +++ b/tests/commands/builds.test.js @@ -167,5 +167,27 @@ describe('commands/builds', () => { assert.strictEqual(capturedFilters.status, 'completed'); assert.strictEqual(capturedFilters.limit, 10); }); + + it('passes project filter to API', async () => { + let output = createMockOutput(); + let capturedFilters = null; + + await buildsCommand( + { project: 'proj-123' }, + { json: true }, + { + loadConfig: async () => ({ apiKey: 'test-token' }), + createApiClient: () => ({}), + getBuilds: async (_client, filters) => { + capturedFilters = filters; + return { builds: [], pagination: { total: 0, hasMore: false } }; + }, + output, + exit: () => {}, + } + ); + + assert.strictEqual(capturedFilters.project, 'proj-123'); + }); }); }); diff --git a/tests/commands/comparisons.test.js b/tests/commands/comparisons.test.js index 8ddf980c..6c6f56a7 100644 --- a/tests/commands/comparisons.test.js +++ b/tests/commands/comparisons.test.js @@ -207,6 +207,31 @@ describe('commands/comparisons', () => { ); }); + it('passes project filter to search', async () => { + let output = createMockOutput(); + let capturedFilters = null; + + await comparisonsCommand( + { name: 'button-*', project: 'my-project' }, + { json: true }, + { + loadConfig: async () => ({ apiKey: 'test-token' }), + createApiClient: () => ({}), + searchComparisons: async (_client, _name, filters) => { + capturedFilters = filters; + return { + comparisons: [], + pagination: { total: 0, hasMore: false }, + }; + }, + output, + exit: () => {}, + } + ); + + assert.strictEqual(capturedFilters.project, 'my-project'); + }); + it('fetches single comparison by ID', async () => { let output = createMockOutput(); let mockComparison = { diff --git a/tests/commands/config-cmd.test.js b/tests/commands/config-cmd.test.js index bc1d1ee5..508c8cc4 100644 --- a/tests/commands/config-cmd.test.js +++ b/tests/commands/config-cmd.test.js @@ -47,7 +47,6 @@ describe('commands/config', () => { comparison: { threshold: 2.0 }, tdd: { openReport: false }, }), - getProjectMapping: async () => null, output, exit: () => {}, } @@ -71,7 +70,6 @@ describe('commands/config', () => { server: { port: 47392 }, comparison: { threshold: 2.5 }, }), - getProjectMapping: async () => null, output, exit: () => {}, } @@ -95,7 +93,6 @@ describe('commands/config', () => { loadConfig: async () => ({ server: { port: 47392 }, }), - getProjectMapping: async () => null, output, exit: code => { exitCode = code; @@ -107,36 +104,6 @@ describe('commands/config', () => { assert.ok(output.calls.some(c => c.method === 'error')); }); - it('includes project mapping if available', async () => { - let output = createMockOutput(); - - await configCommand( - null, - {}, - { json: true }, - { - loadConfig: async () => ({ - server: { port: 47392 }, - }), - getProjectMapping: async () => ({ - projectName: 'My Project', - projectSlug: 'my-project', - organizationSlug: 'my-org', - }), - output, - exit: () => {}, - } - ); - - let dataCall = output.calls.find(c => c.method === 'data'); - assert.ok(dataCall); - assert.deepStrictEqual(dataCall.args[0].project, { - name: 'My Project', - slug: 'my-project', - organization: 'my-org', - }); - }); - it('includes config file path', async () => { let output = createMockOutput(); @@ -149,7 +116,6 @@ describe('commands/config', () => { _configPath: '/path/to/vizzly.config.js', server: { port: 47392 }, }), - getProjectMapping: async () => null, output, exit: () => {}, } @@ -176,7 +142,6 @@ describe('commands/config', () => { apiUrl: 'https://api.vizzly.dev', server: { port: 47392 }, }), - getProjectMapping: async () => null, output, exit: () => {}, } diff --git a/tests/project/core.test.js b/tests/project/core.test.js index 2626f87f..d4395b64 100644 --- a/tests/project/core.test.js +++ b/tests/project/core.test.js @@ -4,7 +4,6 @@ import { VizzlyError } from '../../src/errors/vizzly-error.js'; import { buildBuildsQueryParams, buildBuildsUrl, - buildMappingResult, buildNoApiServiceError, buildNoAuthError, buildOrgHeader, @@ -21,120 +20,9 @@ import { extractProjects, extractToken, extractTokens, - mappingsToArray, - validateDirectory, - validateProjectData, } from '../../src/project/core.js'; describe('project/core', () => { - describe('validateDirectory', () => { - it('returns valid for non-empty directory', () => { - let result = validateDirectory('/path/to/project'); - assert.strictEqual(result.valid, true); - assert.strictEqual(result.error, null); - }); - - it('returns error for empty directory', () => { - let result = validateDirectory(''); - assert.strictEqual(result.valid, false); - assert.ok(result.error instanceof VizzlyError); - assert.strictEqual(result.error.code, 'INVALID_DIRECTORY'); - }); - - it('returns error for null directory', () => { - let result = validateDirectory(null); - assert.strictEqual(result.valid, false); - assert.ok(result.error.message.includes('Directory path is required')); - }); - - it('returns error for undefined directory', () => { - let result = validateDirectory(undefined); - assert.strictEqual(result.valid, false); - }); - }); - - describe('validateProjectData', () => { - it('returns valid for complete project data', () => { - let result = validateProjectData({ - projectSlug: 'my-project', - organizationSlug: 'my-org', - token: 'vzt_token_123', - }); - assert.strictEqual(result.valid, true); - assert.strictEqual(result.error, null); - }); - - it('returns error for missing projectSlug', () => { - let result = validateProjectData({ - organizationSlug: 'my-org', - token: 'vzt_token_123', - }); - assert.strictEqual(result.valid, false); - assert.strictEqual(result.error.code, 'INVALID_PROJECT_DATA'); - assert.ok(result.error.message.includes('Project slug is required')); - }); - - it('returns error for missing organizationSlug', () => { - let result = validateProjectData({ - projectSlug: 'my-project', - token: 'vzt_token_123', - }); - assert.strictEqual(result.valid, false); - assert.ok(result.error.message.includes('Organization slug is required')); - }); - - it('returns error for missing token', () => { - let result = validateProjectData({ - projectSlug: 'my-project', - organizationSlug: 'my-org', - }); - assert.strictEqual(result.valid, false); - assert.ok(result.error.message.includes('Project token is required')); - }); - }); - - describe('mappingsToArray', () => { - it('converts empty object to empty array', () => { - assert.deepStrictEqual(mappingsToArray({}), []); - }); - - it('converts mappings object to array with directory property', () => { - let mappings = { - '/path/to/project1': { projectSlug: 'proj1', token: 'tok1' }, - '/path/to/project2': { projectSlug: 'proj2', token: 'tok2' }, - }; - - let result = mappingsToArray(mappings); - - assert.strictEqual(result.length, 2); - assert.deepStrictEqual(result[0], { - directory: '/path/to/project1', - projectSlug: 'proj1', - token: 'tok1', - }); - assert.deepStrictEqual(result[1], { - directory: '/path/to/project2', - projectSlug: 'proj2', - token: 'tok2', - }); - }); - }); - - describe('buildMappingResult', () => { - it('builds mapping result with directory included', () => { - let result = buildMappingResult('/my/path', { - projectSlug: 'proj', - token: 'tok', - }); - - assert.deepStrictEqual(result, { - directory: '/my/path', - projectSlug: 'proj', - token: 'tok', - }); - }); - }); - describe('buildBuildsQueryParams', () => { it('returns empty string for no options', () => { assert.strictEqual(buildBuildsQueryParams(), ''); diff --git a/tests/project/operations.test.js b/tests/project/operations.test.js index 61050f10..78efc762 100644 --- a/tests/project/operations.test.js +++ b/tests/project/operations.test.js @@ -1,166 +1,21 @@ import assert from 'node:assert'; import { describe, it } from 'node:test'; import { - createMapping, createProjectToken, - getMapping, getProject, getProjectWithApiToken, getProjectWithOAuth, getRecentBuilds, getRecentBuildsWithApiToken, getRecentBuildsWithOAuth, - listMappings, listProjects, listProjectsWithApiToken, listProjectsWithOAuth, listProjectTokens, - removeMapping, revokeProjectToken, - switchProject, } from '../../src/project/operations.js'; describe('project/operations', () => { - describe('listMappings', () => { - it('returns array of mappings with directory included', async () => { - let store = createMockMappingStore({ - '/path/a': { projectSlug: 'proj-a', token: 'tok-a' }, - '/path/b': { projectSlug: 'proj-b', token: 'tok-b' }, - }); - - let result = await listMappings(store); - - assert.strictEqual(result.length, 2); - assert.deepStrictEqual(result[0], { - directory: '/path/a', - projectSlug: 'proj-a', - token: 'tok-a', - }); - }); - - it('returns empty array for no mappings', async () => { - let store = createMockMappingStore({}); - - let result = await listMappings(store); - - assert.deepStrictEqual(result, []); - }); - }); - - describe('getMapping', () => { - it('returns mapping for directory', async () => { - let store = createMockMappingStore({}); - store.getMapping = async _dir => ({ projectSlug: 'proj', token: 'tok' }); - - let result = await getMapping(store, '/my/path'); - - assert.deepStrictEqual(result, { projectSlug: 'proj', token: 'tok' }); - }); - - it('returns null for missing directory', async () => { - let store = createMockMappingStore({}); - store.getMapping = async () => null; - - let result = await getMapping(store, '/unknown'); - - assert.strictEqual(result, null); - }); - }); - - describe('createMapping', () => { - it('creates and returns mapping with directory', async () => { - let savedDir = null; - let _savedData = null; - - let store = { - saveMapping: async (dir, data) => { - savedDir = dir; - _savedData = data; - }, - }; - - let result = await createMapping(store, '/my/path', { - projectSlug: 'proj', - organizationSlug: 'org', - token: 'tok', - }); - - assert.strictEqual(savedDir, '/my/path'); - assert.deepStrictEqual(result, { - directory: '/my/path', - projectSlug: 'proj', - organizationSlug: 'org', - token: 'tok', - }); - }); - - it('throws for invalid directory', async () => { - let store = { saveMapping: async () => {} }; - - await assert.rejects( - () => createMapping(store, '', { projectSlug: 'p' }), - { - code: 'INVALID_DIRECTORY', - } - ); - }); - - it('throws for missing project data', async () => { - let store = { saveMapping: async () => {} }; - - await assert.rejects(() => createMapping(store, '/path', {}), { - code: 'INVALID_PROJECT_DATA', - }); - }); - }); - - describe('removeMapping', () => { - it('removes mapping for directory', async () => { - let deletedDir = null; - let store = { - deleteMapping: async dir => { - deletedDir = dir; - }, - }; - - await removeMapping(store, '/my/path'); - - assert.strictEqual(deletedDir, '/my/path'); - }); - - it('throws for invalid directory', async () => { - let store = { deleteMapping: async () => {} }; - - await assert.rejects(() => removeMapping(store, ''), { - code: 'INVALID_DIRECTORY', - }); - }); - }); - - describe('switchProject', () => { - it('creates mapping with project data', async () => { - let savedDir = null; - let savedData = null; - - let store = { - saveMapping: async (dir, data) => { - savedDir = dir; - savedData = data; - }, - }; - - let result = await switchProject(store, '/path', 'proj', 'org', 'tok'); - - assert.strictEqual(savedDir, '/path'); - assert.deepStrictEqual(savedData, { - projectSlug: 'proj', - organizationSlug: 'org', - token: 'tok', - }); - assert.strictEqual(result.projectSlug, 'proj'); - }); - }); - describe('listProjectsWithOAuth', () => { it('fetches projects for all organizations', async () => { let client = createMockOAuthClient({ @@ -619,15 +474,6 @@ describe('project/operations', () => { // Test helpers -function createMockMappingStore(mappings) { - return { - getMappings: async () => mappings, - getMapping: async dir => mappings[dir] || null, - saveMapping: async () => {}, - deleteMapping: async () => {}, - }; -} - function createMockOAuthClient(responses) { return { authenticatedRequest: async (endpoint, _options = {}) => { diff --git a/tests/server/routers/projects.test.js b/tests/server/routers/projects.test.js deleted file mode 100644 index f9f4d76b..00000000 --- a/tests/server/routers/projects.test.js +++ /dev/null @@ -1,394 +0,0 @@ -import assert from 'node:assert'; -import { EventEmitter } from 'node:events'; -import { describe, it } from 'node:test'; -import { createProjectsRouter } from '../../../src/server/routers/projects.js'; - -/** - * Creates a mock HTTP request with body support - */ -function createMockRequest(method = 'GET', body = null) { - let emitter = new EventEmitter(); - emitter.method = method; - - if (body !== null) { - process.nextTick(() => { - emitter.emit('data', JSON.stringify(body)); - emitter.emit('end'); - }); - } - - return emitter; -} - -/** - * Creates a mock HTTP response with tracking - */ -function createMockResponse() { - let headers = {}; - let statusCode = null; - let body = null; - - return { - get statusCode() { - return statusCode; - }, - set statusCode(code) { - statusCode = code; - }, - setHeader(name, value) { - headers[name] = value; - }, - getHeader(name) { - return headers[name]; - }, - end(content) { - body = content; - }, - get headers() { - return headers; - }, - get body() { - return body; - }, - getParsedBody() { - return body ? JSON.parse(body) : null; - }, - }; -} - -/** - * Creates a mock URL object - */ -function createMockUrl(params = {}) { - return { - searchParams: { - get: key => params[key] || null, - }, - }; -} - -/** - * Creates a mock project service - */ -function createMockProjectService(options = {}) { - return { - listProjects: async () => { - if (options.listError) throw options.listError; - return options.projects || [{ slug: 'project-1' }, { slug: 'project-2' }]; - }, - listMappings: async () => { - if (options.listMappingsError) throw options.listMappingsError; - return options.mappings || { '/project': { projectSlug: 'proj' } }; - }, - createMapping: async (directory, data) => { - if (options.createMappingError) throw options.createMappingError; - return { directory, ...data }; - }, - removeMapping: async _directory => { - if (options.removeMappingError) throw options.removeMappingError; - }, - getMapping: async _directory => { - if (options.getMappingError) throw options.getMappingError; - return options.mapping || null; - }, - getRecentBuilds: async (_projectSlug, _orgSlug, _filters) => { - if (options.getBuildsError) throw options.getBuildsError; - return ( - options.builds || [ - { id: 'build-1', status: 'completed' }, - { id: 'build-2', status: 'running' }, - ] - ); - }, - }; -} - -describe('server/routers/projects', () => { - describe('createProjectsRouter', () => { - it('returns false for unmatched paths', async () => { - let handler = createProjectsRouter({ - projectService: createMockProjectService(), - }); - let req = createMockRequest('GET'); - let res = createMockResponse(); - let url = createMockUrl(); - - let result = await handler(req, res, '/other', url); - - assert.strictEqual(result, false); - }); - - it('returns 503 when projectService is unavailable for project paths', async () => { - let handler = createProjectsRouter({ projectService: null }); - let req = createMockRequest('GET'); - let res = createMockResponse(); - let url = createMockUrl(); - - let result = await handler(req, res, '/api/projects', url); - - assert.strictEqual(result, true); - assert.strictEqual(res.statusCode, 503); - }); - - describe('GET /api/projects', () => { - it('lists projects', async () => { - let handler = createProjectsRouter({ - projectService: createMockProjectService({ - projects: [{ slug: 'proj-a' }, { slug: 'proj-b' }], - }), - }); - let req = createMockRequest('GET'); - let res = createMockResponse(); - let url = createMockUrl(); - - await handler(req, res, '/api/projects', url); - - assert.strictEqual(res.statusCode, 200); - let body = res.getParsedBody(); - assert.strictEqual(body.projects.length, 2); - assert.strictEqual(body.projects[0].slug, 'proj-a'); - }); - - it('returns 500 on error', async () => { - let handler = createProjectsRouter({ - projectService: createMockProjectService({ - listError: new Error('List failed'), - }), - }); - let req = createMockRequest('GET'); - let res = createMockResponse(); - let url = createMockUrl(); - - await handler(req, res, '/api/projects', url); - - assert.strictEqual(res.statusCode, 500); - }); - }); - - describe('GET /api/projects/mappings', () => { - it('lists project mappings', async () => { - let handler = createProjectsRouter({ - projectService: createMockProjectService({ - mappings: { '/dir': { projectSlug: 'proj' } }, - }), - }); - let req = createMockRequest('GET'); - let res = createMockResponse(); - let url = createMockUrl(); - - await handler(req, res, '/api/projects/mappings', url); - - assert.strictEqual(res.statusCode, 200); - let body = res.getParsedBody(); - assert.ok(body.mappings['/dir']); - }); - - it('returns 500 on error', async () => { - let handler = createProjectsRouter({ - projectService: createMockProjectService({ - listMappingsError: new Error('Mappings failed'), - }), - }); - let req = createMockRequest('GET'); - let res = createMockResponse(); - let url = createMockUrl(); - - await handler(req, res, '/api/projects/mappings', url); - - assert.strictEqual(res.statusCode, 500); - }); - }); - - describe('POST /api/projects/mappings', () => { - it('creates project mapping', async () => { - let handler = createProjectsRouter({ - projectService: createMockProjectService(), - }); - let req = createMockRequest('POST', { - directory: '/my-project', - projectSlug: 'proj', - organizationSlug: 'org', - token: 'vzt_123', - }); - let res = createMockResponse(); - let url = createMockUrl(); - - await handler(req, res, '/api/projects/mappings', url); - - assert.strictEqual(res.statusCode, 200); - let body = res.getParsedBody(); - assert.strictEqual(body.success, true); - assert.strictEqual(body.mapping.directory, '/my-project'); - }); - - it('returns 500 on error', async () => { - let handler = createProjectsRouter({ - projectService: createMockProjectService({ - createMappingError: new Error('Create failed'), - }), - }); - let req = createMockRequest('POST', { directory: '/proj' }); - let res = createMockResponse(); - let url = createMockUrl(); - - await handler(req, res, '/api/projects/mappings', url); - - assert.strictEqual(res.statusCode, 500); - }); - }); - - describe('DELETE /api/projects/mappings/:directory', () => { - it('deletes project mapping', async () => { - let handler = createProjectsRouter({ - projectService: createMockProjectService(), - }); - let req = createMockRequest('DELETE'); - let res = createMockResponse(); - let url = createMockUrl(); - - await handler(req, res, '/api/projects/mappings/%2Fmy-project', url); - - assert.strictEqual(res.statusCode, 200); - let body = res.getParsedBody(); - assert.strictEqual(body.success, true); - }); - - it('returns 500 on error', async () => { - let handler = createProjectsRouter({ - projectService: createMockProjectService({ - removeMappingError: new Error('Remove failed'), - }), - }); - let req = createMockRequest('DELETE'); - let res = createMockResponse(); - let url = createMockUrl(); - - await handler(req, res, '/api/projects/mappings/%2Fproj', url); - - assert.strictEqual(res.statusCode, 500); - }); - }); - - describe('GET /api/builds/recent', () => { - it('returns 503 when projectService is unavailable', async () => { - let handler = createProjectsRouter({ projectService: null }); - let req = createMockRequest('GET'); - let res = createMockResponse(); - let url = createMockUrl(); - - await handler(req, res, '/api/builds/recent', url); - - assert.strictEqual(res.statusCode, 503); - }); - - it('returns 400 when no project mapping exists', async () => { - let handler = createProjectsRouter({ - projectService: createMockProjectService({ mapping: null }), - }); - let req = createMockRequest('GET'); - let res = createMockResponse(); - let url = createMockUrl(); - - await handler(req, res, '/api/builds/recent', url); - - assert.strictEqual(res.statusCode, 400); - let body = res.getParsedBody(); - assert.ok(body.error.includes('No project configured')); - }); - - it('returns recent builds', async () => { - let handler = createProjectsRouter({ - projectService: createMockProjectService({ - mapping: { projectSlug: 'proj', organizationSlug: 'org' }, - builds: [{ id: 'b1' }, { id: 'b2' }], - }), - }); - let req = createMockRequest('GET'); - let res = createMockResponse(); - let url = createMockUrl({ limit: '5', branch: 'main' }); - - await handler(req, res, '/api/builds/recent', url); - - assert.strictEqual(res.statusCode, 200); - let body = res.getParsedBody(); - assert.strictEqual(body.builds.length, 2); - }); - - it('returns 500 on error', async () => { - let handler = createProjectsRouter({ - projectService: createMockProjectService({ - mapping: { projectSlug: 'proj', organizationSlug: 'org' }, - getBuildsError: new Error('Builds failed'), - }), - }); - let req = createMockRequest('GET'); - let res = createMockResponse(); - let url = createMockUrl(); - - await handler(req, res, '/api/builds/recent', url); - - assert.strictEqual(res.statusCode, 500); - }); - }); - - describe('GET /api/projects/:org/:project/builds', () => { - it('returns builds for specific project', async () => { - let handler = createProjectsRouter({ - projectService: createMockProjectService({ - builds: [{ id: 'build-a' }], - }), - }); - let req = createMockRequest('GET'); - let res = createMockResponse(); - let url = createMockUrl({ limit: '10' }); - - await handler(req, res, '/api/projects/my-org/my-project/builds', url); - - assert.strictEqual(res.statusCode, 200); - let body = res.getParsedBody(); - assert.strictEqual(body.builds.length, 1); - }); - - it('returns 500 on error', async () => { - let handler = createProjectsRouter({ - projectService: createMockProjectService({ - getBuildsError: new Error('Fetch failed'), - }), - }); - let req = createMockRequest('GET'); - let res = createMockResponse(); - let url = createMockUrl(); - - await handler(req, res, '/api/projects/org/proj/builds', url); - - assert.strictEqual(res.statusCode, 500); - }); - - it('handles URL-encoded org and project slugs', async () => { - let capturedArgs = {}; - let handler = createProjectsRouter({ - projectService: { - ...createMockProjectService(), - getRecentBuilds: async (projectSlug, orgSlug) => { - capturedArgs = { projectSlug, orgSlug }; - return []; - }, - }, - }); - let req = createMockRequest('GET'); - let res = createMockResponse(); - let url = createMockUrl(); - - await handler( - req, - res, - '/api/projects/my%20org/my%20project/builds', - url - ); - - assert.strictEqual(capturedArgs.projectSlug, 'my project'); - assert.strictEqual(capturedArgs.orgSlug, 'my org'); - }); - }); - }); -}); diff --git a/tests/services/project-service.test.js b/tests/services/project-service.test.js deleted file mode 100644 index 54e399c8..00000000 --- a/tests/services/project-service.test.js +++ /dev/null @@ -1,371 +0,0 @@ -import assert from 'node:assert'; -import { describe, it } from 'node:test'; -import { createProjectService } from '../../src/services/project-service.js'; -import { createMockHttpClient } from '../auth/test-helpers.js'; - -/** - * Create an in-memory mapping store for testing - * @param {Object} initialMappings - Initial mappings state (directory -> projectData) - * @returns {Object} Mapping store with getMappings, getMapping, saveMapping, deleteMapping - */ -function createInMemoryMappingStore(initialMappings = {}) { - let mappings = { ...initialMappings }; - - return { - async getMappings() { - return mappings; - }, - async getMapping(directory) { - return mappings[directory] || null; - }, - async saveMapping(directory, projectData) { - mappings[directory] = projectData; - }, - async deleteMapping(directory) { - delete mappings[directory]; - }, - // Test helper to inspect current state - _getState() { - return mappings; - }, - }; -} - -/** - * Create a mock token getter for testing - * @param {Object|null} tokens - Tokens to return - * @returns {Function} Async token getter - */ -function createMockTokenGetter(tokens) { - return async () => tokens; -} - -describe('services/project-service', () => { - describe('listMappings', () => { - it('returns empty array when no mappings exist', async () => { - let mappingStore = createInMemoryMappingStore({}); - let httpClient = createMockHttpClient({}); - let service = createProjectService({ httpClient, mappingStore }); - - let result = await service.listMappings(); - - assert.ok(Array.isArray(result)); - assert.strictEqual(result.length, 0); - }); - - it('returns array of mappings with directory included', async () => { - let mappingStore = createInMemoryMappingStore({ - '/path/to/project': { - projectSlug: 'my-project', - organizationSlug: 'my-org', - }, - '/another/project': { - projectSlug: 'other-project', - organizationSlug: 'other-org', - }, - }); - let httpClient = createMockHttpClient({}); - let service = createProjectService({ httpClient, mappingStore }); - - let result = await service.listMappings(); - - assert.strictEqual(result.length, 2); - let dirs = result.map(m => m.directory); - assert.ok(dirs.includes('/path/to/project')); - assert.ok(dirs.includes('/another/project')); - }); - }); - - describe('getMapping', () => { - it('returns null for non-existent directory', async () => { - let mappingStore = createInMemoryMappingStore({}); - let httpClient = createMockHttpClient({}); - let service = createProjectService({ httpClient, mappingStore }); - - let result = await service.getMapping('/nonexistent/path'); - - assert.strictEqual(result, null); - }); - - it('returns mapping for existing directory', async () => { - let mappingStore = createInMemoryMappingStore({ - '/my/project': { - projectSlug: 'my-project', - organizationSlug: 'my-org', - token: 'project-token', - }, - }); - let httpClient = createMockHttpClient({}); - let service = createProjectService({ httpClient, mappingStore }); - - let result = await service.getMapping('/my/project'); - - assert.strictEqual(result.projectSlug, 'my-project'); - assert.strictEqual(result.organizationSlug, 'my-org'); - }); - }); - - describe('createMapping', () => { - it('creates new mapping', async () => { - let mappingStore = createInMemoryMappingStore({}); - let httpClient = createMockHttpClient({}); - let service = createProjectService({ httpClient, mappingStore }); - - let result = await service.createMapping('/new/project', { - projectSlug: 'new-project', - organizationSlug: 'new-org', - token: 'project-token', - }); - - assert.strictEqual(result.directory, '/new/project'); - assert.strictEqual(result.projectSlug, 'new-project'); - - // Verify it was persisted - let stored = mappingStore._getState(); - assert.ok(stored['/new/project']); - }); - - it('validates directory is required', async () => { - let mappingStore = createInMemoryMappingStore({}); - let httpClient = createMockHttpClient({}); - let service = createProjectService({ httpClient, mappingStore }); - - await assert.rejects( - () => - service.createMapping('', { - projectSlug: 'project', - organizationSlug: 'org', - }), - /directory/i - ); - }); - - it('validates project data is required', async () => { - let mappingStore = createInMemoryMappingStore({}); - let httpClient = createMockHttpClient({}); - let service = createProjectService({ httpClient, mappingStore }); - - await assert.rejects( - () => service.createMapping('/some/path', {}), - /required/i - ); - }); - - it('validates projectSlug is required', async () => { - let mappingStore = createInMemoryMappingStore({}); - let httpClient = createMockHttpClient({}); - let service = createProjectService({ httpClient, mappingStore }); - - await assert.rejects( - () => service.createMapping('/some/path', { organizationSlug: 'org' }), - /required/i - ); - }); - }); - - describe('removeMapping', () => { - it('removes existing mapping', async () => { - let mappingStore = createInMemoryMappingStore({ - '/my/project': { projectSlug: 'my-project', organizationSlug: 'org' }, - }); - let httpClient = createMockHttpClient({}); - let service = createProjectService({ httpClient, mappingStore }); - - await service.removeMapping('/my/project'); - - let stored = mappingStore._getState(); - assert.strictEqual(stored['/my/project'], undefined); - }); - - it('validates directory is required', async () => { - let mappingStore = createInMemoryMappingStore({}); - let httpClient = createMockHttpClient({}); - let service = createProjectService({ httpClient, mappingStore }); - - await assert.rejects(() => service.removeMapping(''), /directory/i); - }); - }); - - describe('listProjects', () => { - it('returns empty array when not authenticated', async () => { - let mappingStore = createInMemoryMappingStore({}); - let httpClient = createMockHttpClient({}); - let getAuthTokens = createMockTokenGetter(null); - let service = createProjectService({ - httpClient, - mappingStore, - getAuthTokens, - }); - - let result = await service.listProjects(); - - assert.ok(Array.isArray(result)); - assert.strictEqual(result.length, 0); - }); - - it('returns projects when authenticated', async () => { - let mappingStore = createInMemoryMappingStore({}); - let httpClient = createMockHttpClient({ - '/api/auth/cli/whoami': { - user: { email: 'test@example.com' }, - organizations: [{ slug: 'my-org', name: 'My Org' }], - }, - '/api/project': { - projects: [ - { slug: 'project-1', name: 'Project 1' }, - { slug: 'project-2', name: 'Project 2' }, - ], - }, - }); - let getAuthTokens = createMockTokenGetter({ - accessToken: 'valid-token', - refreshToken: 'refresh', - }); - let service = createProjectService({ - httpClient, - mappingStore, - getAuthTokens, - }); - - let result = await service.listProjects(); - - assert.strictEqual(result.length, 2); - assert.strictEqual(result[0].slug, 'project-1'); - assert.strictEqual(result[1].slug, 'project-2'); - }); - - it('enriches projects with organization info', async () => { - let mappingStore = createInMemoryMappingStore({}); - let httpClient = createMockHttpClient({ - '/api/auth/cli/whoami': { - user: { email: 'test@example.com' }, - organizations: [{ slug: 'my-org', name: 'My Org' }], - }, - '/api/project': { - projects: [{ slug: 'project-1', name: 'Project 1' }], - }, - }); - let getAuthTokens = createMockTokenGetter({ - accessToken: 'valid-token', - refreshToken: 'refresh', - }); - let service = createProjectService({ - httpClient, - mappingStore, - getAuthTokens, - }); - - let result = await service.listProjects(); - - assert.strictEqual(result[0].organizationSlug, 'my-org'); - assert.strictEqual(result[0].organizationName, 'My Org'); - }); - }); - - describe('getRecentBuilds', () => { - it('returns empty array when not authenticated', async () => { - let mappingStore = createInMemoryMappingStore({}); - let httpClient = createMockHttpClient({}); - let getAuthTokens = createMockTokenGetter(null); - let service = createProjectService({ - httpClient, - mappingStore, - getAuthTokens, - }); - - let result = await service.getRecentBuilds('project', 'org', { - limit: 10, - }); - - assert.ok(Array.isArray(result)); - assert.strictEqual(result.length, 0); - }); - - it('returns builds when authenticated', async () => { - let mappingStore = createInMemoryMappingStore({}); - let httpClient = createMockHttpClient({ - '/api/build/my-project': { - builds: [ - { id: 'build-1', branch: 'main', status: 'passed' }, - { id: 'build-2', branch: 'feature', status: 'failed' }, - ], - }, - }); - let getAuthTokens = createMockTokenGetter({ - accessToken: 'valid-token', - refreshToken: 'refresh', - }); - let service = createProjectService({ - httpClient, - mappingStore, - getAuthTokens, - }); - - let result = await service.getRecentBuilds('my-project', 'my-org', { - limit: 10, - }); - - assert.strictEqual(result.length, 2); - assert.strictEqual(result[0].id, 'build-1'); - assert.strictEqual(result[1].id, 'build-2'); - }); - - it('passes query options to API', async () => { - let mappingStore = createInMemoryMappingStore({}); - let httpClient = createMockHttpClient({ - '/api/build/my-project': { - builds: [{ id: 'build-1', branch: 'main' }], - }, - }); - let getAuthTokens = createMockTokenGetter({ - accessToken: 'valid-token', - refreshToken: 'refresh', - }); - let service = createProjectService({ - httpClient, - mappingStore, - getAuthTokens, - }); - - await service.getRecentBuilds('my-project', 'my-org', { - limit: 5, - branch: 'main', - }); - - // Verify the request was made (we can inspect the endpoint called) - let calls = httpClient._getCalls(); - assert.ok(calls.length > 0); - }); - }); - - describe('httpClient caching', () => { - it('reuses same httpClient for multiple API calls', async () => { - let mappingStore = createInMemoryMappingStore({}); - let httpClient = createMockHttpClient({ - '/api/auth/cli/whoami': { - user: { email: 'test@example.com' }, - organizations: [{ slug: 'org' }], - }, - '/api/project': { projects: [] }, - '/api/build/p1': { builds: [] }, - }); - let getAuthTokens = createMockTokenGetter({ - accessToken: 'token', - refreshToken: 'refresh', - }); - let service = createProjectService({ - httpClient, - mappingStore, - getAuthTokens, - }); - - // Make multiple API calls - await service.listProjects(); - await service.getRecentBuilds('p1', 'org', {}); - - // All calls should have gone through the same httpClient - let calls = httpClient._getCalls(); - assert.ok(calls.length >= 2); - }); - }); -}); diff --git a/tests/utils/context.test.js b/tests/utils/context.test.js index a881bbc5..dcaddb4c 100644 --- a/tests/utils/context.test.js +++ b/tests/utils/context.test.js @@ -114,31 +114,6 @@ describe('utils/context', () => { assert.strictEqual(serverItem.value, 'running on :47392'); }); - it('detects project mapping from global config', () => { - process.chdir(testDir); - - // Create global config with project mapping - let globalConfig = { - projects: { - [testDir]: { - projectName: 'Test Project', - organizationSlug: 'test-org', - }, - }, - }; - writeFileSync( - join(process.env.VIZZLY_HOME, 'config.json'), - JSON.stringify(globalConfig) - ); - - let items = getContext(); - - let projectItem = items.find(i => i.label === 'Project'); - assert.ok(projectItem); - assert.strictEqual(projectItem.type, 'success'); - assert.strictEqual(projectItem.value, 'Test Project (test-org)'); - }); - it('detects logged in user from global config', () => { process.chdir(testDir); @@ -266,7 +241,6 @@ describe('utils/context', () => { assert.strictEqual(context.tddServer.running, false); assert.strictEqual(context.tddServer.port, null); assert.strictEqual(context.project.hasConfig, false); - assert.strictEqual(context.project.mapping, null); assert.strictEqual(context.auth.loggedIn, false); assert.strictEqual(context.auth.hasEnvToken, false); assert.strictEqual(context.baselines.count, 0); diff --git a/tests/utils/global-config.test.js b/tests/utils/global-config.test.js index 950cec53..e9fda1a0 100644 --- a/tests/utils/global-config.test.js +++ b/tests/utils/global-config.test.js @@ -11,18 +11,14 @@ import { afterEach, beforeEach, describe, it } from 'node:test'; import { clearAuthTokens, clearGlobalConfig, - deleteProjectMapping, getAccessToken, getAuthTokens, getGlobalConfigDir, getGlobalConfigPath, - getProjectMapping, - getProjectMappings, hasValidTokens, loadGlobalConfig, saveAuthTokens, saveGlobalConfig, - saveProjectMapping, } from '../../src/utils/global-config.js'; describe('utils/global-config', () => { @@ -288,107 +284,4 @@ describe('utils/global-config', () => { assert.strictEqual(token, 'my-token'); }); }); - - describe('getProjectMapping', () => { - it('returns null when no projects', async () => { - let mapping = await getProjectMapping('/some/path'); - - assert.strictEqual(mapping, null); - }); - - it('returns mapping for exact path', async () => { - await saveProjectMapping('/project/path', { - token: 'vzt_123', - projectSlug: 'my-project', - }); - - let mapping = await getProjectMapping('/project/path'); - - assert.strictEqual(mapping.token, 'vzt_123'); - assert.strictEqual(mapping.projectSlug, 'my-project'); - }); - - it('returns mapping from parent directory', async () => { - await saveProjectMapping('/project', { - token: 'vzt_123', - projectSlug: 'parent-project', - }); - - let mapping = await getProjectMapping('/project/subdir/nested'); - - assert.strictEqual(mapping.projectSlug, 'parent-project'); - }); - - it('returns null when no ancestor has mapping', async () => { - await saveProjectMapping('/other/project', { - token: 'vzt_123', - }); - - let mapping = await getProjectMapping('/different/path'); - - assert.strictEqual(mapping, null); - }); - }); - - describe('saveProjectMapping', () => { - it('saves project mapping with timestamp', async () => { - await saveProjectMapping('/project', { - token: 'vzt_123', - projectSlug: 'test', - organizationSlug: 'org', - }); - - let config = await loadGlobalConfig(); - - assert.ok(config.projects['/project']); - assert.strictEqual(config.projects['/project'].token, 'vzt_123'); - assert.ok(config.projects['/project'].createdAt); - }); - - it('creates projects object if not exists', async () => { - await saveProjectMapping('/new-project', { token: 'vzt_abc' }); - - let config = await loadGlobalConfig(); - - assert.ok(config.projects); - }); - }); - - describe('getProjectMappings', () => { - it('returns empty object when no projects', async () => { - let mappings = await getProjectMappings(); - - assert.deepStrictEqual(mappings, {}); - }); - - it('returns all project mappings', async () => { - await saveProjectMapping('/project1', { token: 'vzt_1' }); - await saveProjectMapping('/project2', { token: 'vzt_2' }); - - let mappings = await getProjectMappings(); - - assert.ok(mappings['/project1']); - assert.ok(mappings['/project2']); - }); - }); - - describe('deleteProjectMapping', () => { - it('removes project mapping', async () => { - await saveProjectMapping('/project', { token: 'vzt_123' }); - await deleteProjectMapping('/project'); - - let mapping = await getProjectMapping('/project'); - - assert.strictEqual(mapping, null); - }); - - it('does nothing when mapping does not exist', async () => { - // Should not throw - await deleteProjectMapping('/nonexistent'); - - let mappings = await getProjectMappings(); - - assert.deepStrictEqual(mappings, {}); - }); - }); });