Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions lib/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ export interface ListOptions {
offset?: number;
query?: string;
self_only?: boolean;
app_config_id?: string;
}

export interface RunTestsOptions {
Expand Down Expand Up @@ -589,6 +590,7 @@ export class ApiClient {
if (options.limit !== undefined) params.append('limit', options.limit.toString());
if (options.offset !== undefined) params.append('offset', options.offset.toString());
if (options.query) params.append('query', options.query);
if (options.app_config_id) params.append('app_config_id', options.app_config_id);

if (options.self_only === undefined || options.self_only === true) {
params.append('self_only', 'true');
Expand Down
6 changes: 5 additions & 1 deletion src/cli/commands/test/export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import * as yaml from 'yaml';
import { ApiClient } from '../../../../lib/api/index.js';
import type { TestDefinition } from '../../../types/test-definition.js';
import { loadConfig } from '../../lib/config.js';
import { error, formatError, info, success } from '../../lib/output.js';
import { error, formatError, info, success, warning } from '../../lib/output.js';

export const exportCommand = new Command('export')
.description('Export a cloud test to a local file')
Expand All @@ -19,6 +19,10 @@ export const exportCommand = new Command('export')
.option('--no-deps', 'Exclude dependency tests')
.option('--stdout', 'Output to stdout instead of file')
.action(async (testId, options) => {
// Deprecation warning
console.log(warning('The "export" command is deprecated. Use "qa-use test sync pull --id <test-id>" instead.'));
console.log('');

try {
const config = await loadConfig();

Expand Down
201 changes: 178 additions & 23 deletions src/cli/commands/test/sync.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/**
* qa-use test sync - Sync local and cloud tests
* qa-use test sync - Sync local tests with cloud
*/

import * as fs from 'node:fs/promises';
Expand All @@ -12,12 +12,17 @@ import { loadConfig } from '../../lib/config.js';
import { discoverTests, loadTestDefinition } from '../../lib/loader.js';
import { error, formatError, info, success, warning } from '../../lib/output.js';

// Parent command
export const syncCommand = new Command('sync')
.description('Sync local tests with cloud')
.option('--pull', 'Pull tests from cloud to local (default)')
.option('--push', 'Push local tests to cloud')
.description('Sync local tests with cloud');

// Pull subcommand
const pullCommand = new Command('pull')
.description('Pull tests from cloud to local')
.option('--id <uuid>', 'Pull single test by ID')
.option('--app-config-id <id>', 'Pull tests for specific app config')
.option('--dry-run', 'Show what would be synced without making changes')
.option('--force', 'Overwrite existing files/tests without prompting')
.option('--force', 'Overwrite existing files without prompting')
.action(async (options) => {
try {
const config = await loadConfig();
Expand All @@ -35,30 +40,112 @@ export const syncCommand = new Command('sync')

const testDir = config.test_directory || './qa-tests';

if (options.push) {
await pushToCloud(client, testDir, options.dryRun, options.force);
} else {
// Default to pull
await pullFromCloud(client, testDir, options.dryRun, options.force);
await pullFromCloud(client, testDir, {
dryRun: options.dryRun,
force: options.force,
testId: options.id,
appConfigId: options.appConfigId,
});
} catch (err) {
console.log(error(`Sync failed: ${formatError(err)}`));
process.exit(1);
}
});

// Push subcommand
const pushCommand = new Command('push')
.description('Push local tests to cloud')
.option('--id <uuid>', 'Push single test by ID')
.option('--all', 'Push all local tests')
.option('--dry-run', 'Show what would be synced without making changes')
.option('--force', 'Overwrite cloud tests without version check')
.action(async (options) => {
// Require --id or --all
if (!options.id && !options.all) {
console.log(error('Must specify --id <uuid> or --all'));
console.log(' Use --id to push a single test');
console.log(' Use --all to push all local tests');
process.exit(1);
}

try {
const config = await loadConfig();

// Check API key
if (!config.api_key) {
console.log(error('API key not configured'));
console.log(' Run `qa-use setup` to configure');
process.exit(1);
}

// Initialize API client
const client = new ApiClient(config.api_url);
client.setApiKey(config.api_key);

const testDir = config.test_directory || './qa-tests';

await pushToCloud(client, testDir, {
dryRun: options.dryRun,
force: options.force,
testId: options.id,
});
} catch (err) {
console.log(error(`Sync failed: ${formatError(err)}`));
process.exit(1);
}
});

syncCommand.addCommand(pullCommand);
syncCommand.addCommand(pushCommand);

interface PullOptions {
dryRun?: boolean;
force?: boolean;
testId?: string;
appConfigId?: string;
}

/**
* Pull tests from cloud to local files
*/
async function pullFromCloud(
client: ApiClient,
testDir: string,
dryRun: boolean = false,
force: boolean = false
options: PullOptions = {}
): Promise<void> {
const { dryRun = false, force = false, testId, appConfigId } = options;

// Single test by ID
if (testId) {
console.log(info(`Fetching test ${testId} from cloud...\n`));

if (dryRun) {
console.log(` Would pull: test ${testId}`);
console.log('');
console.log(info('Dry run complete.'));
return;
}

try {
const content = await client.exportTest(testId, 'yaml', true);
await writeTestsFromContent(content, testDir, force);
} catch (err) {
console.log(error(`Failed to export test ${testId}: ${formatError(err)}`));
process.exit(1);
}
return;
}

// List tests with optional app_config_id filter
console.log(info('Fetching tests from cloud...\n'));

const cloudTests = await client.listTests({ limit: 100 });
const listOptions: { limit: number; app_config_id?: string } = { limit: 100 };
if (appConfigId) {
listOptions.app_config_id = appConfigId;
console.log(info(`Filtering by app config: ${appConfigId}\n`));
}

const cloudTests = await client.listTests(listOptions);

if (cloudTests.length === 0) {
console.log(warning('No tests found in cloud'));
Expand Down Expand Up @@ -149,6 +236,55 @@ async function pullFromCloud(
}
}

/**
* Write tests from YAML content to files
*/
async function writeTestsFromContent(
content: string,
testDir: string,
force: boolean
): Promise<void> {
// Ensure directory exists
await fs.mkdir(testDir, { recursive: true });

// Parse multi-document YAML
const docs = yaml.parseAllDocuments(content);
const tests = docs.map((d) => d.toJSON() as TestDefinition);

let pulled = 0;
let skipped = 0;

for (const testDef of tests) {
const safeName = (testDef.name || testDef.id || 'unnamed-test')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '');
const outputPath = path.join(testDir, `${safeName}.yaml`);

// Check if file exists
let exists = false;
try {
await fs.access(outputPath);
exists = true;
} catch {
// File doesn't exist
}

if (exists && !force) {
console.log(` Skip: ${safeName}.yaml (exists, use --force to overwrite)`);
skipped++;
} else {
const testContent = yaml.stringify(testDef);
await fs.writeFile(outputPath, testContent, 'utf-8');
console.log(success(` ${safeName}.yaml`));
pulled++;
}
}

console.log('');
console.log(success(`Pulled ${pulled} test(s), skipped ${skipped}`));
}

/**
* Update version_hash in a local YAML file
* @internal Exported for testing
Expand All @@ -160,15 +296,22 @@ export async function updateLocalVersionHash(filePath: string, versionHash: stri
await fs.writeFile(filePath, doc.toString(), 'utf-8');
}

interface PushOptions {
dryRun?: boolean;
force?: boolean;
testId?: string;
}

/**
* Push local tests to cloud
*/
async function pushToCloud(
client: ApiClient,
testDir: string,
dryRun: boolean = false,
force: boolean = false
options: PushOptions = {}
): Promise<void> {
const { dryRun = false, force = false, testId } = options;

console.log(info(`Loading local tests from ${testDir}...\n`));

const files = await discoverTests(testDir);
Expand All @@ -179,8 +322,6 @@ async function pushToCloud(
return;
}

console.log(`Found ${files.length} local test(s)\n`);

// Load all test definitions
const definitions: Array<{ file: string; def: TestDefinition }> = [];
for (const file of files) {
Expand All @@ -197,21 +338,35 @@ async function pushToCloud(
return;
}

// Filter to single test if --id provided
let toImport = definitions;
if (testId) {
toImport = definitions.filter((d) => d.def.id === testId || d.def.name === testId);
if (toImport.length === 0) {
console.log(error(`Test not found: ${testId}`));
console.log(' Searched by ID and name in local test files');
process.exit(1);
}
console.log(`Found ${toImport.length} matching test(s)\n`);
} else {
console.log(`Found ${definitions.length} local test(s)\n`);
}

if (dryRun) {
console.log(info('Dry run - would push:'));
for (const { file, def } of definitions) {
for (const { file, def } of toImport) {
console.log(` ${def.name || path.basename(file)}`);
}
console.log('');
console.log(info(`Would import ${definitions.length} test(s)`));
console.log(info(`Would import ${toImport.length} test(s)`));
return;
}

// Import to cloud
console.log('Importing to cloud...\n');

const result = await client.importTestDefinition(
definitions.map((d) => d.def),
toImport.map((d) => d.def),
{ upsert: true, force }
);

Expand All @@ -232,7 +387,7 @@ async function pushToCloud(
case 'conflict': {
console.log(warning(` CONFLICT: ${imported.name} - ${imported.message}`));
// Find the local file for this conflict
const localDef = definitions.find(
const localDef = toImport.find(
(d) => d.def.id === imported.id || d.def.name === imported.name
);
if (localDef) {
Expand All @@ -255,7 +410,7 @@ async function pushToCloud(
imported.version_hash &&
(imported.action === 'created' || imported.action === 'updated')
) {
const localDef = definitions.find(
const localDef = toImport.find(
(d) => d.def.id === imported.id || d.def.name === imported.name
);
if (localDef) {
Expand All @@ -278,7 +433,7 @@ async function pushToCloud(
console.log(` qa-use test diff ${file}`);
}
console.log('');
console.log(info('Then use --force to overwrite, or --pull to get latest versions.'));
console.log(info('Then use --force to overwrite, or pull to get latest versions.'));
}

console.log(success(`Pushed ${result.imported.length} test(s)`));
Expand Down
Loading