Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
4 changes: 4 additions & 0 deletions src/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -582,6 +582,10 @@ program
.option('--base <path>', 'Override auto-detected base path')
.option('--open', 'Open preview URL in browser after upload')
.option('--dry-run', 'Show what would be uploaded without uploading')
.option(
'--public-link',
'Acknowledge that preview URL grants access to anyone with the link (required for private projects)'
)
.option(
'-x, --exclude <pattern>',
'Exclude files/dirs (repeatable, e.g. -x "*.log" -x "temp/")',
Expand Down
56 changes: 49 additions & 7 deletions src/commands/preview.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { join, resolve } from 'node:path';
import { promisify } from 'node:util';
import {
createApiClient as defaultCreateApiClient,
getBuild as defaultGetBuild,
uploadPreviewZip as defaultUploadPreviewZip,
} from '../api/index.js';
import { openBrowser as defaultOpenBrowser } from '../utils/browser.js';
Expand Down Expand Up @@ -336,6 +337,7 @@ export async function previewCommand(
let {
loadConfig = defaultLoadConfig,
createApiClient = defaultCreateApiClient,
getBuild = defaultGetBuild,
uploadPreviewZip = defaultUploadPreviewZip,
readSession = defaultReadSession,
formatSessionAge = defaultFormatSessionAge,
Expand Down Expand Up @@ -436,6 +438,52 @@ export async function previewCommand(
return { success: false, reason: 'no-build' };
}

// Create API client for non-dry-run operations (reused for visibility check and upload)
let client;
if (!options.dryRun) {
client = createApiClient({
baseUrl: config.apiUrl,
token: config.apiKey,
command: 'preview',
});

// Check project visibility for private projects
let build;
try {
build = await getBuild(client, buildId);
} catch (error) {
if (error.status === 404) {
output.error(`Build not found: ${buildId}`);
} else {
output.error('Failed to verify project visibility', error);
}
exit(1);
// Return is for testing (exit is mocked in tests)
return { success: false, reason: 'build-fetch-failed', error };
}

// Check if project is private and user hasn't acknowledged public link access
// Use === false to handle undefined/missing isPublic defensively
let isPrivate = build.project && build.project.isPublic === false;
if (isPrivate && !options.publicLink) {
output.error('This project is private.');
output.blank();
output.print(
' Preview URLs grant access to anyone with the link (until expiration).'
);
output.blank();
output.print(' To proceed, acknowledge this by using:');
output.blank();
output.print(' vizzly preview ./dist --public-link');
output.blank();
output.print(' Or set your project to public in Vizzly settings.');
output.blank();
exit(1);
// Return is for testing (exit is mocked in tests)
return { success: false, reason: 'private-project-no-flag' };
}
}

// Check for zip command availability (skip for dry-run)
if (!options.dryRun) {
let zipInfo = getZipCommand();
Expand Down Expand Up @@ -611,14 +659,8 @@ export async function previewCommand(
`Compressed to ${formatBytes(zipBuffer.length)} (${compressionRatio}% smaller)`
);

// Upload
// Upload (reuse client created earlier)
output.updateSpinner('Uploading preview...');
let client = createApiClient({
baseUrl: config.apiUrl,
token: config.apiKey,
command: 'preview',
});

let result = await uploadPreviewZip(client, buildId, zipBuffer);
output.stopSpinner();

Expand Down
195 changes: 195 additions & 0 deletions tests/commands/preview.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,7 @@ describe('previewCommand', () => {
createApiClient: () => ({
request: async () => ({}),
}),
getBuild: async () => ({ project: { isPublic: true } }),
uploadPreviewZip: async (_client, buildId) => {
capturedBuildId = buildId;
return {
Expand Down Expand Up @@ -244,6 +245,7 @@ describe('previewCommand', () => {
}),
detectBranch: async () => 'main',
createApiClient: () => ({}),
getBuild: async () => ({ project: { isPublic: true } }),
uploadPreviewZip: async (_client, buildId) => {
capturedBuildId = buildId;
return {
Expand Down Expand Up @@ -311,6 +313,7 @@ describe('previewCommand', () => {
apiUrl: 'https://api.test',
}),
createApiClient: () => ({}),
getBuild: async () => ({ project: { isPublic: true } }),
uploadPreviewZip: async () => ({
previewUrl: 'https://preview.test',
uploaded: 3,
Expand Down Expand Up @@ -344,6 +347,7 @@ describe('previewCommand', () => {
apiUrl: 'https://api.test',
}),
createApiClient: () => ({}),
getBuild: async () => ({ project: { isPublic: true } }),
uploadPreviewZip: async () => ({
previewUrl: 'https://preview.test',
uploaded: 3,
Expand Down Expand Up @@ -562,4 +566,195 @@ describe('previewCommand', () => {
'Should exclude tsconfig.json'
);
});

it('fails when getBuild returns 404', async () => {
let output = createMockOutput();
let exitCode = null;

let result = await previewCommand(
distDir,
{ build: 'nonexistent-build' },
{},
{
loadConfig: async () => ({
apiKey: 'test-token',
apiUrl: 'https://api.test',
}),
createApiClient: () => ({}),
getBuild: async () => {
let error = new Error('Not found');
error.status = 404;
throw error;
},
output,
exit: code => {
exitCode = code;
},
}
);

assert.strictEqual(exitCode, 1);
assert.strictEqual(result.success, false);
assert.strictEqual(result.reason, 'build-fetch-failed');
assert.ok(
output.calls.some(
c => c.method === 'error' && c.args[0].includes('Build not found')
),
'Should show build not found error'
);
});

it('fails when getBuild throws network error', async () => {
let output = createMockOutput();
let exitCode = null;

let result = await previewCommand(
distDir,
{ build: 'build-123' },
{},
{
loadConfig: async () => ({
apiKey: 'test-token',
apiUrl: 'https://api.test',
}),
createApiClient: () => ({}),
getBuild: async () => {
throw new Error('Network error');
},
output,
exit: code => {
exitCode = code;
},
}
);

assert.strictEqual(exitCode, 1);
assert.strictEqual(result.success, false);
assert.strictEqual(result.reason, 'build-fetch-failed');
assert.ok(
output.calls.some(
c =>
c.method === 'error' &&
c.args[0].includes('Failed to verify project visibility')
),
'Should show visibility check error'
);
});

it('fails for private project without --public-link flag', async () => {
let output = createMockOutput();
let exitCode = null;

let result = await previewCommand(
distDir,
{ build: 'build-123' },
{},
{
loadConfig: async () => ({
apiKey: 'test-token',
apiUrl: 'https://api.test',
}),
createApiClient: () => ({}),
getBuild: async () => ({
id: 'build-123',
project: { id: 'proj-1', name: 'Test Project', isPublic: false },
}),
uploadPreviewZip: async () => {
throw new Error('Should not reach upload');
},
output,
exit: code => {
exitCode = code;
},
}
);

assert.strictEqual(exitCode, 1);
assert.strictEqual(result.success, false);
assert.strictEqual(result.reason, 'private-project-no-flag');
assert.ok(
output.calls.some(
c => c.method === 'error' && c.args[0].includes('private')
),
'Should show private project error'
);
assert.ok(
output.calls.some(
c => c.method === 'print' && c.args[0].includes('--public-link')
),
'Should mention --public-link flag in output'
);
});

it('succeeds for private project with --public-link flag', async () => {
let output = createMockOutput();
let uploadCalled = false;

let result = await previewCommand(
distDir,
{ build: 'build-123', publicLink: true },
{},
{
loadConfig: async () => ({
apiKey: 'test-token',
apiUrl: 'https://api.test',
}),
createApiClient: () => ({}),
getBuild: async () => ({
id: 'build-123',
project: { id: 'proj-1', name: 'Test Project', isPublic: false },
}),
uploadPreviewZip: async () => {
uploadCalled = true;
return {
previewUrl: 'https://preview.test',
uploaded: 3,
totalBytes: 1000,
newBytes: 800,
};
},
output,
exit: () => {},
}
);

assert.strictEqual(uploadCalled, true, 'Should call upload');
assert.strictEqual(result.success, true);
});

it('succeeds for public project without --public-link flag', async () => {
let output = createMockOutput();
let uploadCalled = false;

let result = await previewCommand(
distDir,
{ build: 'build-123' },
{},
{
loadConfig: async () => ({
apiKey: 'test-token',
apiUrl: 'https://api.test',
}),
createApiClient: () => ({}),
getBuild: async () => ({
id: 'build-123',
project: { id: 'proj-1', name: 'Test Project', isPublic: true },
}),
uploadPreviewZip: async () => {
uploadCalled = true;
return {
previewUrl: 'https://preview.test',
uploaded: 3,
totalBytes: 1000,
newBytes: 800,
};
},
output,
exit: () => {},
}
);

assert.strictEqual(uploadCalled, true, 'Should call upload');
assert.strictEqual(result.success, true);
});
});