@vizzly-testing/cli 0.20.0 → 0.20.1-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/dist/api/client.js +134 -0
  2. package/dist/api/core.js +341 -0
  3. package/dist/api/endpoints.js +314 -0
  4. package/dist/api/index.js +19 -0
  5. package/dist/auth/client.js +91 -0
  6. package/dist/auth/core.js +176 -0
  7. package/dist/auth/index.js +30 -0
  8. package/dist/auth/operations.js +148 -0
  9. package/dist/cli.js +178 -3
  10. package/dist/client/index.js +144 -77
  11. package/dist/commands/doctor.js +121 -36
  12. package/dist/commands/finalize.js +49 -18
  13. package/dist/commands/init.js +13 -18
  14. package/dist/commands/login.js +49 -55
  15. package/dist/commands/logout.js +17 -9
  16. package/dist/commands/project.js +100 -71
  17. package/dist/commands/run.js +189 -95
  18. package/dist/commands/status.js +101 -66
  19. package/dist/commands/tdd-daemon.js +61 -32
  20. package/dist/commands/tdd.js +104 -98
  21. package/dist/commands/upload.js +78 -34
  22. package/dist/commands/whoami.js +44 -42
  23. package/dist/config/core.js +438 -0
  24. package/dist/config/index.js +13 -0
  25. package/dist/config/operations.js +327 -0
  26. package/dist/index.js +1 -1
  27. package/dist/project/core.js +295 -0
  28. package/dist/project/index.js +13 -0
  29. package/dist/project/operations.js +393 -0
  30. package/dist/reporter/reporter-bundle.css +1 -1
  31. package/dist/reporter/reporter-bundle.iife.js +16 -16
  32. package/dist/screenshot-server/core.js +157 -0
  33. package/dist/screenshot-server/index.js +11 -0
  34. package/dist/screenshot-server/operations.js +183 -0
  35. package/dist/sdk/index.js +3 -2
  36. package/dist/server/handlers/api-handler.js +14 -5
  37. package/dist/server/handlers/tdd-handler.js +191 -53
  38. package/dist/server/http-server.js +9 -3
  39. package/dist/server/routers/baseline.js +58 -0
  40. package/dist/server/routers/dashboard.js +10 -6
  41. package/dist/server/routers/screenshot.js +32 -0
  42. package/dist/server-manager/core.js +186 -0
  43. package/dist/server-manager/index.js +81 -0
  44. package/dist/server-manager/operations.js +209 -0
  45. package/dist/services/build-manager.js +2 -69
  46. package/dist/services/index.js +21 -48
  47. package/dist/services/screenshot-server.js +40 -74
  48. package/dist/services/server-manager.js +45 -80
  49. package/dist/services/test-runner.js +90 -250
  50. package/dist/services/uploader.js +56 -358
  51. package/dist/tdd/core/hotspot-coverage.js +112 -0
  52. package/dist/tdd/core/signature.js +101 -0
  53. package/dist/tdd/index.js +19 -0
  54. package/dist/tdd/metadata/baseline-metadata.js +103 -0
  55. package/dist/tdd/metadata/hotspot-metadata.js +93 -0
  56. package/dist/tdd/services/baseline-downloader.js +151 -0
  57. package/dist/tdd/services/baseline-manager.js +166 -0
  58. package/dist/tdd/services/comparison-service.js +230 -0
  59. package/dist/tdd/services/hotspot-service.js +71 -0
  60. package/dist/tdd/services/result-service.js +123 -0
  61. package/dist/tdd/tdd-service.js +1145 -0
  62. package/dist/test-runner/core.js +255 -0
  63. package/dist/test-runner/index.js +13 -0
  64. package/dist/test-runner/operations.js +483 -0
  65. package/dist/types/client.d.ts +25 -2
  66. package/dist/uploader/core.js +396 -0
  67. package/dist/uploader/index.js +11 -0
  68. package/dist/uploader/operations.js +412 -0
  69. package/dist/utils/colors.js +187 -39
  70. package/dist/utils/config-loader.js +3 -6
  71. package/dist/utils/context.js +228 -0
  72. package/dist/utils/output.js +449 -14
  73. package/docs/api-reference.md +173 -8
  74. package/docs/tui-elements.md +560 -0
  75. package/package.json +13 -13
  76. package/dist/services/api-service.js +0 -412
  77. package/dist/services/auth-service.js +0 -226
  78. package/dist/services/config-service.js +0 -369
  79. package/dist/services/html-report-generator.js +0 -455
  80. package/dist/services/project-service.js +0 -326
  81. package/dist/services/report-generator/report.css +0 -411
  82. package/dist/services/report-generator/viewer.js +0 -102
  83. package/dist/services/static-report-generator.js +0 -207
  84. package/dist/services/tdd-service.js +0 -1437
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Auth Operations - Authentication operations with dependency injection
3
+ *
4
+ * Each operation takes its dependencies as parameters:
5
+ * - httpClient: for making HTTP requests
6
+ * - tokenStore: for reading/writing auth tokens
7
+ *
8
+ * This makes them trivially testable without mocking modules.
9
+ */
10
+
11
+ import { buildDevicePollPayload, buildLogoutPayload, buildRefreshPayload, buildTokenData, validateTokens } from './core.js';
12
+
13
+ // ============================================================================
14
+ // Device Flow Operations
15
+ // ============================================================================
16
+
17
+ /**
18
+ * Initiate OAuth device flow
19
+ * @param {Object} httpClient - HTTP client with request method
20
+ * @returns {Promise<Object>} Device code, user code, verification URL
21
+ */
22
+ export async function initiateDeviceFlow(httpClient) {
23
+ return httpClient.request('/api/auth/cli/device/initiate', {
24
+ method: 'POST',
25
+ headers: {
26
+ 'Content-Type': 'application/json'
27
+ }
28
+ });
29
+ }
30
+
31
+ /**
32
+ * Poll for device authorization
33
+ * @param {Object} httpClient - HTTP client
34
+ * @param {string} deviceCode - Device code from initiate
35
+ * @returns {Promise<Object>} Token data or pending status
36
+ */
37
+ export async function pollDeviceAuthorization(httpClient, deviceCode) {
38
+ return httpClient.request('/api/auth/cli/device/poll', {
39
+ method: 'POST',
40
+ headers: {
41
+ 'Content-Type': 'application/json'
42
+ },
43
+ body: JSON.stringify(buildDevicePollPayload(deviceCode))
44
+ });
45
+ }
46
+
47
+ /**
48
+ * Complete device flow and save tokens
49
+ * @param {Object} tokenStore - Token storage with saveTokens method
50
+ * @param {Object} tokenData - Token response from poll
51
+ * @returns {Promise<Object>} Token data
52
+ */
53
+ export async function completeDeviceFlow(tokenStore, tokenData) {
54
+ await tokenStore.saveTokens(buildTokenData(tokenData));
55
+ return tokenData;
56
+ }
57
+
58
+ // ============================================================================
59
+ // Token Operations
60
+ // ============================================================================
61
+
62
+ /**
63
+ * Refresh access token using refresh token
64
+ * @param {Object} httpClient - HTTP client
65
+ * @param {Object} tokenStore - Token storage
66
+ * @returns {Promise<Object>} New tokens
67
+ */
68
+ export async function refresh(httpClient, tokenStore) {
69
+ let auth = await tokenStore.getTokens();
70
+ let validation = validateTokens(auth, 'refreshToken');
71
+ if (!validation.valid) {
72
+ throw validation.error;
73
+ }
74
+ let response = await httpClient.request('/api/auth/cli/refresh', {
75
+ method: 'POST',
76
+ headers: {
77
+ 'Content-Type': 'application/json'
78
+ },
79
+ body: JSON.stringify(buildRefreshPayload(auth.refreshToken))
80
+ });
81
+
82
+ // Preserve existing user data when refreshing
83
+ await tokenStore.saveTokens(buildTokenData(response, auth.user));
84
+ return response;
85
+ }
86
+
87
+ // ============================================================================
88
+ // Logout Operations
89
+ // ============================================================================
90
+
91
+ /**
92
+ * Logout and revoke tokens
93
+ * @param {Object} httpClient - HTTP client
94
+ * @param {Object} tokenStore - Token storage
95
+ * @returns {Promise<void>}
96
+ */
97
+ export async function logout(httpClient, tokenStore) {
98
+ let auth = await tokenStore.getTokens();
99
+ if (auth?.refreshToken) {
100
+ try {
101
+ await httpClient.request('/api/auth/cli/logout', {
102
+ method: 'POST',
103
+ headers: {
104
+ 'Content-Type': 'application/json'
105
+ },
106
+ body: JSON.stringify(buildLogoutPayload(auth.refreshToken))
107
+ });
108
+ } catch (error) {
109
+ // If server request fails, still clear local tokens
110
+ console.warn('Warning: Failed to revoke tokens on server:', error.message);
111
+ }
112
+ }
113
+ await tokenStore.clearTokens();
114
+ }
115
+
116
+ // ============================================================================
117
+ // User Operations
118
+ // ============================================================================
119
+
120
+ /**
121
+ * Get current user information
122
+ * @param {Object} httpClient - HTTP client
123
+ * @param {Object} tokenStore - Token storage
124
+ * @returns {Promise<Object>} User and organization data
125
+ */
126
+ export async function whoami(httpClient, tokenStore) {
127
+ let auth = await tokenStore.getTokens();
128
+ let validation = validateTokens(auth, 'accessToken');
129
+ if (!validation.valid) {
130
+ throw validation.error;
131
+ }
132
+ return httpClient.authenticatedRequest('/api/auth/cli/whoami', auth.accessToken);
133
+ }
134
+
135
+ /**
136
+ * Check if user is authenticated
137
+ * @param {Object} httpClient - HTTP client
138
+ * @param {Object} tokenStore - Token storage
139
+ * @returns {Promise<boolean>} True if authenticated
140
+ */
141
+ export async function isAuthenticated(httpClient, tokenStore) {
142
+ try {
143
+ await whoami(httpClient, tokenStore);
144
+ return true;
145
+ } catch {
146
+ return false;
147
+ }
148
+ }
package/dist/cli.js CHANGED
@@ -13,13 +13,181 @@ import { tddCommand, validateTddOptions } from './commands/tdd.js';
13
13
  import { runDaemonChild, tddStartCommand, tddStatusCommand, tddStopCommand } from './commands/tdd-daemon.js';
14
14
  import { uploadCommand, validateUploadOptions } from './commands/upload.js';
15
15
  import { validateWhoamiOptions, whoamiCommand } from './commands/whoami.js';
16
- import { loadPlugins } from './plugin-loader.js';
17
16
  import { createPluginServices } from './plugin-api.js';
17
+ import { loadPlugins } from './plugin-loader.js';
18
18
  import { createServices } from './services/index.js';
19
+ import { colors } from './utils/colors.js';
19
20
  import { loadConfig } from './utils/config-loader.js';
21
+ import { getContext } from './utils/context.js';
20
22
  import * as output from './utils/output.js';
21
23
  import { getPackageVersion } from './utils/package-info.js';
22
- program.name('vizzly').description('Vizzly CLI for visual regression testing').version(getPackageVersion()).option('-c, --config <path>', 'Config file path').option('--token <token>', 'Vizzly API token').option('-v, --verbose', 'Verbose output (shorthand for --log-level debug)').option('--log-level <level>', 'Log level: debug, info, warn, error (default: info, or VIZZLY_LOG_LEVEL env var)').option('--json', 'Machine-readable output').option('--no-color', 'Disable colored output');
24
+
25
+ // Custom help formatting with Observatory design system
26
+ const formatHelp = (cmd, helper) => {
27
+ let c = colors;
28
+ let lines = [];
29
+ let isRootCommand = !cmd.parent;
30
+ let version = getPackageVersion();
31
+
32
+ // Branded header with grizzly bear
33
+ lines.push('');
34
+ if (isRootCommand) {
35
+ // Cute grizzly bear mascot with square eyes (like the Vizzly logo!)
36
+ lines.push(c.brand.amber(' ʕ□ᴥ□ʔ'));
37
+ lines.push(` ${c.brand.amber(c.bold('vizzly'))} ${c.dim(`v${version}`)}`);
38
+ lines.push(` ${c.gray('Visual regression testing for UI teams')}`);
39
+ } else {
40
+ // Compact header for subcommands
41
+ lines.push(` ${c.brand.amber(c.bold('vizzly'))} ${c.white(cmd.name())}`);
42
+ let desc = cmd.description();
43
+ if (desc) {
44
+ lines.push(` ${c.gray(desc)}`);
45
+ }
46
+ }
47
+ lines.push('');
48
+
49
+ // Usage
50
+ let usage = helper.commandUsage(cmd).replace('Usage: ', '');
51
+ lines.push(` ${c.dim('Usage')} ${c.white(usage)}`);
52
+ lines.push('');
53
+
54
+ // Get all subcommands
55
+ let commands = helper.visibleCommands(cmd);
56
+ if (commands.length > 0) {
57
+ if (isRootCommand) {
58
+ // Group commands by category for root help with icons
59
+ let categories = [{
60
+ key: 'core',
61
+ icon: '▸',
62
+ title: 'Core',
63
+ names: ['run', 'tdd', 'upload', 'status', 'finalize']
64
+ }, {
65
+ key: 'setup',
66
+ icon: '▸',
67
+ title: 'Setup',
68
+ names: ['init', 'doctor']
69
+ }, {
70
+ key: 'auth',
71
+ icon: '▸',
72
+ title: 'Account',
73
+ names: ['login', 'logout', 'whoami']
74
+ }, {
75
+ key: 'project',
76
+ icon: '▸',
77
+ title: 'Projects',
78
+ names: ['project:select', 'project:list', 'project:token', 'project:remove']
79
+ }];
80
+ let grouped = {
81
+ core: [],
82
+ setup: [],
83
+ auth: [],
84
+ project: [],
85
+ other: []
86
+ };
87
+ for (let command of commands) {
88
+ let name = command.name();
89
+ if (name === 'help') continue;
90
+ let found = false;
91
+ for (let cat of categories) {
92
+ if (cat.names.includes(name)) {
93
+ grouped[cat.key].push(command);
94
+ found = true;
95
+ break;
96
+ }
97
+ }
98
+ if (!found) grouped.other.push(command);
99
+ }
100
+ for (let cat of categories) {
101
+ let cmds = grouped[cat.key];
102
+ if (cmds.length === 0) continue;
103
+ lines.push(` ${c.brand.amber(cat.icon)} ${c.bold(cat.title)}`);
104
+ for (let command of cmds) {
105
+ let name = command.name();
106
+ let desc = command.description() || '';
107
+ // Truncate long descriptions
108
+ if (desc.length > 48) desc = `${desc.substring(0, 45)}...`;
109
+ lines.push(` ${c.white(name.padEnd(18))} ${c.gray(desc)}`);
110
+ }
111
+ lines.push('');
112
+ }
113
+
114
+ // Plugins (other commands from plugins)
115
+ if (grouped.other.length > 0) {
116
+ lines.push(` ${c.brand.amber('▸')} ${c.bold('Plugins')}`);
117
+ for (let command of grouped.other) {
118
+ let name = command.name();
119
+ let desc = command.description() || '';
120
+ if (desc.length > 48) desc = `${desc.substring(0, 45)}...`;
121
+ lines.push(` ${c.white(name.padEnd(18))} ${c.gray(desc)}`);
122
+ }
123
+ lines.push('');
124
+ }
125
+ } else {
126
+ // For subcommands, simple list
127
+ lines.push(` ${c.brand.amber('▸')} ${c.bold('Commands')}`);
128
+ for (let command of commands) {
129
+ let name = command.name();
130
+ if (name === 'help') continue;
131
+ let desc = command.description() || '';
132
+ if (desc.length > 48) desc = `${desc.substring(0, 45)}...`;
133
+ lines.push(` ${c.white(name.padEnd(18))} ${c.gray(desc)}`);
134
+ }
135
+ lines.push('');
136
+ }
137
+ }
138
+
139
+ // Options - use dimmer styling for less visual weight
140
+ let options = helper.visibleOptions(cmd);
141
+ if (options.length > 0) {
142
+ lines.push(` ${c.brand.amber('▸')} ${c.bold('Options')}`);
143
+ for (let option of options) {
144
+ let flags = option.flags;
145
+ let desc = option.description || '';
146
+ if (desc.length > 40) desc = `${desc.substring(0, 37)}...`;
147
+ lines.push(` ${c.cyan(flags.padEnd(22))} ${c.dim(desc)}`);
148
+ }
149
+ lines.push('');
150
+ }
151
+
152
+ // Quick start examples (only for root command)
153
+ if (isRootCommand) {
154
+ lines.push(` ${c.brand.amber('▸')} ${c.bold('Quick Start')}`);
155
+ lines.push('');
156
+ lines.push(` ${c.dim('# Local visual testing')}`);
157
+ lines.push(` ${c.gray('$')} ${c.white('vizzly tdd start')}`);
158
+ lines.push('');
159
+ lines.push(` ${c.dim('# CI pipeline')}`);
160
+ lines.push(` ${c.gray('$')} ${c.white('vizzly run "npm test" --wait')}`);
161
+ lines.push('');
162
+ }
163
+
164
+ // Dynamic context section (only for root)
165
+ if (isRootCommand) {
166
+ let contextItems = getContext();
167
+ if (contextItems.length > 0) {
168
+ lines.push(` ${c.dim('─'.repeat(52))}`);
169
+ for (let item of contextItems) {
170
+ if (item.type === 'success') {
171
+ lines.push(` ${c.green('✓')} ${c.gray(item.label)} ${c.white(item.value)}`);
172
+ } else if (item.type === 'warning') {
173
+ lines.push(` ${c.yellow('!')} ${c.gray(item.label)} ${c.yellow(item.value)}`);
174
+ } else {
175
+ lines.push(` ${c.dim('○')} ${c.gray(item.label)} ${c.dim(item.value)}`);
176
+ }
177
+ }
178
+ lines.push('');
179
+ }
180
+ }
181
+
182
+ // Footer with links
183
+ lines.push(` ${c.dim('─'.repeat(52))}`);
184
+ lines.push(` ${c.dim('Docs')} ${c.cyan(c.underline('docs.vizzly.dev'))} ${c.dim('GitHub')} ${c.cyan(c.underline('github.com/vizzly-testing/cli'))}`);
185
+ lines.push('');
186
+ return lines.join('\n');
187
+ };
188
+ program.name('vizzly').description('Vizzly CLI for visual regression testing').version(getPackageVersion()).option('-c, --config <path>', 'Config file path').option('--token <token>', 'Vizzly API token').option('-v, --verbose', 'Verbose output (shorthand for --log-level debug)').option('--log-level <level>', 'Log level: debug, info, warn, error (default: info, or VIZZLY_LOG_LEVEL env var)').option('--json', 'Machine-readable output').option('--color', 'Force colored output (even in non-TTY)').option('--no-color', 'Disable colored output').configureHelp({
189
+ formatHelp
190
+ });
23
191
 
24
192
  // Load plugins before defining commands
25
193
  // We need to manually parse to get the config option early
@@ -40,10 +208,17 @@ for (let i = 0; i < process.argv.length; i++) {
40
208
 
41
209
  // Configure output early
42
210
  // Priority: --log-level > --verbose > VIZZLY_LOG_LEVEL env var > default ('info')
211
+ // Color priority: --no-color (off) > --color (on) > auto-detect
212
+ let colorOverride;
213
+ if (process.argv.includes('--no-color')) {
214
+ colorOverride = false;
215
+ } else if (process.argv.includes('--color')) {
216
+ colorOverride = true;
217
+ }
43
218
  output.configure({
44
219
  logLevel: logLevelArg,
45
220
  verbose: verboseMode,
46
- color: !process.argv.includes('--no-color'),
221
+ color: colorOverride,
47
222
  json: process.argv.includes('--json')
48
223
  });
49
224
  const config = await loadConfig(configPath, {});
@@ -4,17 +4,40 @@
4
4
  */
5
5
 
6
6
  import { existsSync, readFileSync } from 'node:fs';
7
+ import { request as httpRequest } from 'node:http';
8
+ import { request as httpsRequest } from 'node:https';
7
9
  import { dirname, join, parse } from 'node:path';
8
10
  import { getBuildId, getServerUrl, isTddMode, setVizzlyEnabled } from '../utils/environment-config.js';
9
11
 
10
12
  // Internal client state
11
13
  let currentClient = null;
12
14
  let isDisabled = false;
13
- let hasLoggedWarning = false;
14
15
 
15
16
  // Default timeout for screenshot requests (30 seconds)
16
17
  const DEFAULT_TIMEOUT_MS = 30000;
17
18
 
19
+ // Log levels for client SDK output control
20
+ export const LOG_LEVELS = {
21
+ debug: 0,
22
+ info: 1,
23
+ warn: 2,
24
+ error: 3
25
+ };
26
+
27
+ /**
28
+ * Check if client should log at the given level
29
+ * Respects VIZZLY_CLIENT_LOG_LEVEL env var (default: 'error' - only show errors)
30
+ * @param {string} level - Log level to check
31
+ * @param {string} [configuredLevel] - Configured log level (defaults to env var)
32
+ * @returns {boolean} Whether to log at this level
33
+ */
34
+ export function shouldLogClient(level, configuredLevel) {
35
+ let configLevel = configuredLevel || process.env.VIZZLY_CLIENT_LOG_LEVEL?.toLowerCase() || 'error';
36
+ let levelValue = LOG_LEVELS[level] ?? 0;
37
+ let configValue = LOG_LEVELS[configLevel] ?? 3;
38
+ return levelValue >= configValue;
39
+ }
40
+
18
41
  /**
19
42
  * Check if Vizzly is currently disabled
20
43
  * @private
@@ -28,31 +51,32 @@ function isVizzlyDisabled() {
28
51
  /**
29
52
  * Disable Vizzly SDK for the current session
30
53
  * @private
31
- * @param {string} [reason] - Optional reason for disabling
32
54
  */
33
- function disableVizzly(reason = 'disabled') {
55
+ function disableVizzly() {
34
56
  isDisabled = true;
35
57
  currentClient = null;
36
- if (reason !== 'disabled') {
37
- console.warn(`Vizzly SDK disabled due to ${reason}. Screenshots will be skipped for the remainder of this session.`);
38
- }
39
58
  }
40
59
 
41
60
  /**
42
61
  * Auto-discover local TDD server by checking for server.json
43
- * @private
62
+ * @param {string} [startDir] - Directory to start search from (defaults to cwd)
63
+ * @param {Object} [deps] - Injectable dependencies for testing
44
64
  * @returns {string|null} Server URL if found
45
65
  */
46
- function autoDiscoverTddServer() {
66
+ export function autoDiscoverTddServer(startDir, deps = {}) {
67
+ let {
68
+ exists = existsSync,
69
+ readFile = readFileSync
70
+ } = deps;
47
71
  try {
48
72
  // Look for .vizzly/server.json in current directory and parent directories
49
- let currentDir = process.cwd();
73
+ let currentDir = startDir || process.cwd();
50
74
  const root = parse(currentDir).root;
51
75
  while (currentDir !== root) {
52
76
  const serverJsonPath = join(currentDir, '.vizzly', 'server.json');
53
- if (existsSync(serverJsonPath)) {
77
+ if (exists(serverJsonPath)) {
54
78
  try {
55
- const serverInfo = JSON.parse(readFileSync(serverJsonPath, 'utf8'));
79
+ const serverInfo = JSON.parse(readFile(serverJsonPath, 'utf8'));
56
80
  if (serverInfo.port) {
57
81
  const url = `http://localhost:${serverInfo.port}`;
58
82
  return url;
@@ -97,6 +121,63 @@ function getClient() {
97
121
  return currentClient;
98
122
  }
99
123
 
124
+ /**
125
+ * Make HTTP/HTTPS request without keep-alive (so process can exit promptly)
126
+ * @private
127
+ * @param {string} url - Full URL to POST to (http or https)
128
+ * @param {object} body - JSON-serializable request body
129
+ * @param {number} timeoutMs - Request timeout in milliseconds
130
+ * @returns {Promise<{status: number, json: any}>} Response status and parsed JSON body
131
+ */
132
+ function httpPost(url, body, timeoutMs) {
133
+ return new Promise((resolve, reject) => {
134
+ let parsedUrl = new URL(url);
135
+ let data = JSON.stringify(body);
136
+ let isHttps = parsedUrl.protocol === 'https:';
137
+ let request = isHttps ? httpsRequest : httpRequest;
138
+ let req = request({
139
+ hostname: parsedUrl.hostname,
140
+ port: parsedUrl.port || (isHttps ? 443 : 80),
141
+ path: parsedUrl.pathname,
142
+ method: 'POST',
143
+ headers: {
144
+ 'Content-Type': 'application/json',
145
+ 'Content-Length': Buffer.byteLength(data),
146
+ Connection: 'close'
147
+ },
148
+ agent: false // Disable keep-alive agent so process can exit promptly
149
+ }, res => {
150
+ let chunks = [];
151
+ res.on('data', chunk => chunks.push(chunk));
152
+ res.on('end', () => {
153
+ let responseBody = Buffer.concat(chunks).toString();
154
+ let json = null;
155
+ try {
156
+ json = JSON.parse(responseBody);
157
+ } catch (err) {
158
+ if (shouldLogClient('debug')) {
159
+ console.debug(`[vizzly] Failed to parse response: ${err.message}`);
160
+ }
161
+ json = {
162
+ error: responseBody
163
+ };
164
+ }
165
+ resolve({
166
+ status: res.statusCode,
167
+ json
168
+ });
169
+ });
170
+ });
171
+ req.on('error', reject);
172
+ req.setTimeout(timeoutMs, () => {
173
+ req.destroy();
174
+ reject(new Error('Request timeout'));
175
+ });
176
+ req.write(data);
177
+ req.end();
178
+ });
179
+ }
180
+
100
181
  /**
101
182
  * Create a simple HTTP client for screenshots
102
183
  * @private
@@ -104,50 +185,29 @@ function getClient() {
104
185
  function createSimpleClient(serverUrl) {
105
186
  return {
106
187
  async screenshot(name, imageBuffer, options = {}) {
107
- const controller = new AbortController();
108
- const timeoutId = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT_MS);
109
188
  try {
110
189
  // If it's a string, assume it's a file path and send directly
111
190
  // Otherwise it's a Buffer, so convert to base64
112
191
  const image = typeof imageBuffer === 'string' ? imageBuffer : imageBuffer.toString('base64');
113
- const response = await fetch(`${serverUrl}/screenshot`, {
114
- method: 'POST',
115
- headers: {
116
- 'Content-Type': 'application/json'
117
- },
118
- body: JSON.stringify({
119
- buildId: getBuildId(),
120
- name,
121
- image,
122
- properties: options,
123
- fullPage: options.fullPage || false
124
- }),
125
- signal: controller.signal
126
- });
127
- if (!response.ok) {
128
- const errorData = await response.json().catch(async () => {
129
- const errorText = await response.text().catch(() => 'Unknown error');
130
- return {
131
- error: errorText
132
- };
133
- });
134
-
135
- // In TDD mode, if we get 422 (visual difference), log but DON'T throw
192
+ const {
193
+ status,
194
+ json
195
+ } = await httpPost(`${serverUrl}/screenshot`, {
196
+ buildId: getBuildId(),
197
+ name,
198
+ image,
199
+ properties: options,
200
+ fullPage: options.fullPage || false
201
+ }, DEFAULT_TIMEOUT_MS);
202
+ if (status < 200 || status >= 300) {
203
+ // In TDD mode, if we get 422 (visual difference), don't throw
136
204
  // This allows all screenshots in the test to be captured and compared
137
- if (response.status === 422 && errorData.tddMode && errorData.comparison) {
138
- const comp = errorData.comparison;
139
- const diffPercent = comp.diffPercentage ? comp.diffPercentage.toFixed(2) : '0.00';
140
-
141
- // Extract port from serverUrl (e.g., "http://localhost:47392" -> "47392")
142
- const urlMatch = serverUrl.match(/:(\d+)/);
143
- const port = urlMatch ? urlMatch[1] : '47392';
144
- const dashboardUrl = `http://localhost:${port}/dashboard`;
145
-
146
- // Just log warning - don't throw by default in TDD mode
147
- // This allows all screenshots to be captured
148
- console.warn(`⚠️ Visual diff: ${comp.name} (${diffPercent}%) → ${dashboardUrl}`);
205
+ // The summary will show all failures at the end
206
+ if (status === 422 && json.tddMode && json.comparison) {
207
+ let comp = json.comparison;
149
208
 
150
209
  // Return success so test continues and captures remaining screenshots
210
+ // Visual diff details will be shown in the summary after tests complete
151
211
  return {
152
212
  success: true,
153
213
  status: 'failed',
@@ -155,46 +215,56 @@ function createSimpleClient(serverUrl) {
155
215
  diffPercentage: comp.diffPercentage
156
216
  };
157
217
  }
158
- throw new Error(`Screenshot failed: ${response.status} ${response.statusText} - ${errorData.error || 'Unknown error'}`);
218
+ throw new Error(`Screenshot failed: ${status} - ${json.error || 'Unknown error'}`);
159
219
  }
160
- clearTimeout(timeoutId);
161
- return await response.json();
220
+ return json;
162
221
  } catch (error) {
163
- clearTimeout(timeoutId);
164
-
165
- // Handle timeout (AbortError)
166
- if (error.name === 'AbortError') {
167
- console.error(`Vizzly screenshot timed out for "${name}" after ${DEFAULT_TIMEOUT_MS / 1000}s`);
168
- console.error('The server may be overloaded or unresponsive. Check server health.');
169
- disableVizzly('timeout');
222
+ // Handle timeout
223
+ if (error.message === 'Request timeout') {
224
+ if (shouldLogClient('error')) {
225
+ console.error(`[vizzly] Screenshot timed out for "${name}" after ${DEFAULT_TIMEOUT_MS / 1000}s`);
226
+ }
227
+ disableVizzly();
170
228
  return null;
171
229
  }
172
230
 
173
231
  // In TDD mode with visual differences, throw the error to fail the test
174
232
  if (error.message?.toLowerCase().includes('visual diff')) {
175
- // Clean output for TDD mode - don't spam with additional logs
176
233
  throw error;
177
234
  }
178
- console.error(`Vizzly screenshot failed for ${name}:`, error.message);
179
- if (error.message?.includes('fetch') || error.code === 'ECONNREFUSED') {
180
- console.error(`Server URL: ${serverUrl}/screenshot`);
181
- console.error('This usually means the Vizzly server is not running or not accessible');
182
- console.error('Check that the server is started and the port is correct');
183
- } else if (error.message?.includes('404') || error.message?.includes('Not Found')) {
184
- console.error(`Server URL: ${serverUrl}/screenshot`);
185
- console.error('The screenshot endpoint was not found - check server configuration');
235
+
236
+ // Log connection errors (these indicate setup problems)
237
+ if (shouldLogClient('error')) {
238
+ if (error.code === 'ECONNREFUSED') {
239
+ console.error(`[vizzly] Server not accessible at ${serverUrl}/screenshot`);
240
+ } else if (error.message?.includes('404') || error.message?.includes('Not Found')) {
241
+ console.error(`[vizzly] Screenshot endpoint not found at ${serverUrl}/screenshot`);
242
+ } else {
243
+ console.error(`[vizzly] Screenshot failed for ${name}: ${error.message}`);
244
+ }
186
245
  }
187
246
 
188
247
  // Disable the SDK after first failure to prevent spam
189
- disableVizzly('failure');
248
+ disableVizzly();
190
249
 
191
- // Don't throw - just return silently to not break tests (except TDD mode)
250
+ // Don't throw - just return silently to not break tests
192
251
  return null;
193
252
  }
194
253
  },
195
254
  async flush() {
196
- // Simple client doesn't need explicit flushing
197
- return Promise.resolve();
255
+ // Call the /flush endpoint to signal test completion and trigger summary output
256
+ try {
257
+ let {
258
+ status,
259
+ json
260
+ } = await httpPost(`${serverUrl}/flush`, {}, DEFAULT_TIMEOUT_MS);
261
+ if (status >= 200 && status < 300) {
262
+ return json;
263
+ }
264
+ } catch {
265
+ // Silently ignore flush errors - server may not be running
266
+ }
267
+ return null;
198
268
  }
199
269
  };
200
270
  }
@@ -240,13 +310,10 @@ export async function vizzlyScreenshot(name, imageBuffer, options = {}) {
240
310
  if (isVizzlyDisabled()) {
241
311
  return; // Silently skip when disabled
242
312
  }
243
- const client = getClient();
313
+ let client = getClient();
244
314
  if (!client) {
245
- if (!hasLoggedWarning) {
246
- console.warn('Vizzly client not initialized. Screenshots will be skipped.');
247
- hasLoggedWarning = true;
248
- disableVizzly();
249
- }
315
+ // Silently disable - no server running, nothing to do
316
+ disableVizzly();
250
317
  return;
251
318
  }
252
319