@vizzly-testing/cli 0.16.2 → 0.16.4
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.
|
@@ -27,7 +27,7 @@ export class ApiService {
|
|
|
27
27
|
const sdkUserAgent = options.userAgent || getUserAgent();
|
|
28
28
|
this.userAgent = sdkUserAgent ? `${baseUserAgent} ${sdkUserAgent}` : baseUserAgent;
|
|
29
29
|
if (!this.token && !options.allowNoToken) {
|
|
30
|
-
throw new VizzlyError('No API token provided. Set VIZZLY_TOKEN environment variable or
|
|
30
|
+
throw new VizzlyError('No API token provided. Set VIZZLY_TOKEN environment variable or link a project in the TDD dashboard.');
|
|
31
31
|
}
|
|
32
32
|
}
|
|
33
33
|
|
|
@@ -101,10 +101,10 @@ export class ApiService {
|
|
|
101
101
|
// Token refresh failed, fall through to auth error
|
|
102
102
|
}
|
|
103
103
|
}
|
|
104
|
-
throw new AuthError('Invalid or expired API token.
|
|
104
|
+
throw new AuthError('Invalid or expired API token. Link a project via "vizzly project:select" or set VIZZLY_TOKEN.');
|
|
105
105
|
}
|
|
106
106
|
if (response.status === 401) {
|
|
107
|
-
throw new AuthError('Invalid or expired API token.
|
|
107
|
+
throw new AuthError('Invalid or expired API token. Link a project via "vizzly project:select" or set VIZZLY_TOKEN.');
|
|
108
108
|
}
|
|
109
109
|
throw new VizzlyError(`API request failed: ${response.status}${errorText ? ` - ${errorText}` : ''} (URL: ${url})`);
|
|
110
110
|
}
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
import { createHttpServer } from '../server/http-server.js';
|
|
7
7
|
import { createTddHandler } from '../server/handlers/tdd-handler.js';
|
|
8
8
|
import { createApiHandler } from '../server/handlers/api-handler.js';
|
|
9
|
-
import { writeFileSync, mkdirSync } from 'fs';
|
|
9
|
+
import { writeFileSync, mkdirSync, unlinkSync, existsSync } from 'fs';
|
|
10
10
|
import { join } from 'path';
|
|
11
11
|
export class ServerManager {
|
|
12
12
|
constructor(config, options = {}) {
|
|
@@ -85,6 +85,16 @@ export class ServerManager {
|
|
|
85
85
|
// Don't throw - cleanup errors shouldn't fail the stop process
|
|
86
86
|
}
|
|
87
87
|
}
|
|
88
|
+
|
|
89
|
+
// Clean up server.json so the client SDK doesn't try to connect to a dead server
|
|
90
|
+
try {
|
|
91
|
+
let serverFile = join(process.cwd(), '.vizzly', 'server.json');
|
|
92
|
+
if (existsSync(serverFile)) {
|
|
93
|
+
unlinkSync(serverFile);
|
|
94
|
+
}
|
|
95
|
+
} catch {
|
|
96
|
+
// Non-fatal - cleanup errors shouldn't fail the stop process
|
|
97
|
+
}
|
|
88
98
|
}
|
|
89
99
|
|
|
90
100
|
// Expose server interface for compatibility
|
|
@@ -94,4 +104,13 @@ export class ServerManager {
|
|
|
94
104
|
finishBuild: buildId => this.httpServer?.finishBuild?.(buildId)
|
|
95
105
|
};
|
|
96
106
|
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Get TDD results (comparisons, screenshot count, etc.)
|
|
110
|
+
* Only available in TDD mode after tests have run
|
|
111
|
+
*/
|
|
112
|
+
async getTddResults() {
|
|
113
|
+
if (!this.tddMode || !this.handler?.getResults) return null;
|
|
114
|
+
return await this.handler.getResults();
|
|
115
|
+
}
|
|
97
116
|
}
|
|
@@ -13,14 +13,20 @@ import { sanitizeScreenshotName, validatePathSecurity, safePath, validateScreens
|
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
15
|
* Generate a screenshot signature for baseline matching
|
|
16
|
-
* Uses same logic as screenshot-identity.js: name + viewport_width + browser
|
|
16
|
+
* Uses same logic as screenshot-identity.js: name + viewport_width + browser + custom properties
|
|
17
17
|
*
|
|
18
18
|
* Matches backend signature generation which uses:
|
|
19
19
|
* - screenshot.name
|
|
20
20
|
* - screenshot.viewport_width (top-level property)
|
|
21
21
|
* - screenshot.browser (top-level property)
|
|
22
|
+
* - custom properties from project's baseline_signature_properties setting
|
|
23
|
+
*
|
|
24
|
+
* @param {string} name - Screenshot name
|
|
25
|
+
* @param {Object} properties - Screenshot properties (viewport, browser, metadata, etc.)
|
|
26
|
+
* @param {Array<string>} customProperties - Custom property names from project settings
|
|
27
|
+
* @returns {string} Signature like "Login|1920|chrome|iPhone 15 Pro"
|
|
22
28
|
*/
|
|
23
|
-
function generateScreenshotSignature(name, properties = {}) {
|
|
29
|
+
function generateScreenshotSignature(name, properties = {}, customProperties = []) {
|
|
24
30
|
let parts = [name];
|
|
25
31
|
|
|
26
32
|
// Check for viewport_width as top-level property first (backend format)
|
|
@@ -40,15 +46,30 @@ function generateScreenshotSignature(name, properties = {}) {
|
|
|
40
46
|
if (properties.browser) {
|
|
41
47
|
parts.push(properties.browser);
|
|
42
48
|
}
|
|
49
|
+
|
|
50
|
+
// Add custom properties in order (matches cloud screenshot-identity.js behavior)
|
|
51
|
+
for (let propName of customProperties) {
|
|
52
|
+
// Check multiple locations where property might exist:
|
|
53
|
+
// 1. Top-level property (e.g., properties.device)
|
|
54
|
+
// 2. In metadata object (e.g., properties.metadata.device)
|
|
55
|
+
// 3. In nested metadata.properties (e.g., properties.metadata.properties.device)
|
|
56
|
+
let value = properties[propName] ?? properties.metadata?.[propName] ?? properties.metadata?.properties?.[propName] ?? '';
|
|
57
|
+
|
|
58
|
+
// Normalize: convert to string, trim whitespace
|
|
59
|
+
parts.push(String(value).trim());
|
|
60
|
+
}
|
|
43
61
|
return parts.join('|');
|
|
44
62
|
}
|
|
45
63
|
|
|
46
64
|
/**
|
|
47
65
|
* Create a safe filename from signature
|
|
66
|
+
* Handles custom property values that may contain spaces or special characters
|
|
48
67
|
*/
|
|
49
68
|
function signatureToFilename(signature) {
|
|
50
|
-
|
|
51
|
-
|
|
69
|
+
return signature.replace(/\|/g, '_') // pipes to underscores
|
|
70
|
+
.replace(/\s+/g, '_') // spaces to underscores
|
|
71
|
+
.replace(/[/\\:*?"<>]/g, '') // remove unsafe filesystem chars
|
|
72
|
+
.replace(/_+/g, '_'); // collapse multiple underscores
|
|
52
73
|
}
|
|
53
74
|
|
|
54
75
|
/**
|
|
@@ -92,6 +113,7 @@ export class TddService {
|
|
|
92
113
|
this.baselineData = null;
|
|
93
114
|
this.comparisons = [];
|
|
94
115
|
this.threshold = config.comparison?.threshold || 0.1;
|
|
116
|
+
this.signatureProperties = []; // Custom properties from project's baseline_signature_properties
|
|
95
117
|
|
|
96
118
|
// Check if we're in baseline update mode
|
|
97
119
|
if (this.setBaseline) {
|
|
@@ -138,6 +160,14 @@ export class TddService {
|
|
|
138
160
|
throw new Error(`Build ${buildId} not found or API returned null`);
|
|
139
161
|
}
|
|
140
162
|
|
|
163
|
+
// Extract signature properties from API response (for variant support)
|
|
164
|
+
if (apiResponse.signatureProperties && Array.isArray(apiResponse.signatureProperties)) {
|
|
165
|
+
this.signatureProperties = apiResponse.signatureProperties;
|
|
166
|
+
if (this.signatureProperties.length > 0) {
|
|
167
|
+
output.info(`Using custom signature properties: ${this.signatureProperties.join(', ')}`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
141
171
|
// Handle wrapped response format
|
|
142
172
|
baselineBuild = apiResponse.build || apiResponse;
|
|
143
173
|
if (!baselineBuild.id) {
|
|
@@ -280,8 +310,14 @@ export class TddService {
|
|
|
280
310
|
}
|
|
281
311
|
|
|
282
312
|
// Generate signature for baseline matching (same as compareScreenshot)
|
|
283
|
-
|
|
284
|
-
|
|
313
|
+
// Build properties object with top-level viewport_width and browser
|
|
314
|
+
// These are returned as top-level fields from the API, not inside metadata
|
|
315
|
+
let properties = validateScreenshotProperties({
|
|
316
|
+
viewport_width: screenshot.viewport_width,
|
|
317
|
+
browser: screenshot.browser,
|
|
318
|
+
...(screenshot.metadata || screenshot.properties || {})
|
|
319
|
+
});
|
|
320
|
+
let signature = generateScreenshotSignature(sanitizedName, properties, this.signatureProperties);
|
|
285
321
|
let filename = signatureToFilename(signature);
|
|
286
322
|
const imagePath = safePath(this.baselinePath, `${filename}.png`);
|
|
287
323
|
|
|
@@ -381,6 +417,8 @@ export class TddService {
|
|
|
381
417
|
environment,
|
|
382
418
|
branch,
|
|
383
419
|
threshold: this.threshold,
|
|
420
|
+
signatureProperties: this.signatureProperties,
|
|
421
|
+
// Store for TDD comparison
|
|
384
422
|
createdAt: new Date().toISOString(),
|
|
385
423
|
buildInfo: {
|
|
386
424
|
commitSha: baselineBuild.commit_sha,
|
|
@@ -396,8 +434,15 @@ export class TddService {
|
|
|
396
434
|
output.warn(`Screenshot name sanitization failed for '${s.name}': ${error.message}`);
|
|
397
435
|
return null; // Skip invalid screenshots
|
|
398
436
|
}
|
|
399
|
-
|
|
400
|
-
|
|
437
|
+
|
|
438
|
+
// Build properties object with top-level viewport_width and browser
|
|
439
|
+
// These are returned as top-level fields from the API, not inside metadata
|
|
440
|
+
let properties = validateScreenshotProperties({
|
|
441
|
+
viewport_width: s.viewport_width,
|
|
442
|
+
browser: s.browser,
|
|
443
|
+
...(s.metadata || s.properties || {})
|
|
444
|
+
});
|
|
445
|
+
let signature = generateScreenshotSignature(sanitizedName, properties, this.signatureProperties);
|
|
401
446
|
let filename = signatureToFilename(signature);
|
|
402
447
|
return {
|
|
403
448
|
name: sanitizedName,
|
|
@@ -630,8 +675,17 @@ export class TddService {
|
|
|
630
675
|
});
|
|
631
676
|
let {
|
|
632
677
|
build,
|
|
633
|
-
screenshots
|
|
678
|
+
screenshots,
|
|
679
|
+
signatureProperties
|
|
634
680
|
} = response;
|
|
681
|
+
|
|
682
|
+
// Extract signature properties from API response (for variant support)
|
|
683
|
+
if (signatureProperties && Array.isArray(signatureProperties)) {
|
|
684
|
+
this.signatureProperties = signatureProperties;
|
|
685
|
+
if (this.signatureProperties.length > 0) {
|
|
686
|
+
output.info(`Using custom signature properties: ${this.signatureProperties.join(', ')}`);
|
|
687
|
+
}
|
|
688
|
+
}
|
|
635
689
|
if (!screenshots || screenshots.length === 0) {
|
|
636
690
|
output.warn('⚠️ No screenshots found in build');
|
|
637
691
|
return {
|
|
@@ -668,8 +722,15 @@ export class TddService {
|
|
|
668
722
|
errorCount++;
|
|
669
723
|
continue;
|
|
670
724
|
}
|
|
671
|
-
|
|
672
|
-
|
|
725
|
+
|
|
726
|
+
// Build properties object with top-level viewport_width and browser
|
|
727
|
+
// These are returned as top-level fields from the API, not inside metadata
|
|
728
|
+
let properties = validateScreenshotProperties({
|
|
729
|
+
viewport_width: screenshot.viewport_width,
|
|
730
|
+
browser: screenshot.browser,
|
|
731
|
+
...screenshot.metadata
|
|
732
|
+
});
|
|
733
|
+
let signature = generateScreenshotSignature(sanitizedName, properties, this.signatureProperties);
|
|
673
734
|
let filename = signatureToFilename(signature);
|
|
674
735
|
let filePath = safePath(this.baselinePath, `${filename}.png`);
|
|
675
736
|
|
|
@@ -724,6 +785,8 @@ export class TddService {
|
|
|
724
785
|
buildName: build.name,
|
|
725
786
|
branch: build.branch,
|
|
726
787
|
threshold: this.threshold,
|
|
788
|
+
signatureProperties: this.signatureProperties,
|
|
789
|
+
// Store for TDD comparison
|
|
727
790
|
screenshots: downloadedScreenshots
|
|
728
791
|
};
|
|
729
792
|
let metadataPath = join(this.baselinePath, 'metadata.json');
|
|
@@ -804,6 +867,12 @@ export class TddService {
|
|
|
804
867
|
const metadata = JSON.parse(readFileSync(metadataPath, 'utf8'));
|
|
805
868
|
this.baselineData = metadata;
|
|
806
869
|
this.threshold = metadata.threshold || this.threshold;
|
|
870
|
+
|
|
871
|
+
// Restore signature properties from saved metadata (for variant support)
|
|
872
|
+
this.signatureProperties = metadata.signatureProperties || [];
|
|
873
|
+
if (this.signatureProperties.length > 0) {
|
|
874
|
+
output.debug('tdd', `loaded signature properties: ${this.signatureProperties.join(', ')}`);
|
|
875
|
+
}
|
|
807
876
|
return metadata;
|
|
808
877
|
} catch (error) {
|
|
809
878
|
output.error(`❌ Failed to load baseline metadata: ${error.message}`);
|
|
@@ -833,8 +902,8 @@ export class TddService {
|
|
|
833
902
|
validatedProperties.viewport_width = validatedProperties.viewport.width;
|
|
834
903
|
}
|
|
835
904
|
|
|
836
|
-
// Generate signature for baseline matching (name + viewport_width + browser)
|
|
837
|
-
const signature = generateScreenshotSignature(sanitizedName, validatedProperties);
|
|
905
|
+
// Generate signature for baseline matching (name + viewport_width + browser + custom props)
|
|
906
|
+
const signature = generateScreenshotSignature(sanitizedName, validatedProperties, this.signatureProperties);
|
|
838
907
|
const filename = signatureToFilename(signature);
|
|
839
908
|
const currentImagePath = safePath(this.currentPath, `${filename}.png`);
|
|
840
909
|
const baselineImagePath = safePath(this.baselinePath, `${filename}.png`);
|
|
@@ -1227,7 +1296,7 @@ export class TddService {
|
|
|
1227
1296
|
continue;
|
|
1228
1297
|
}
|
|
1229
1298
|
let validatedProperties = validateScreenshotProperties(comparison.properties || {});
|
|
1230
|
-
let signature = generateScreenshotSignature(sanitizedName, validatedProperties);
|
|
1299
|
+
let signature = generateScreenshotSignature(sanitizedName, validatedProperties, this.signatureProperties);
|
|
1231
1300
|
let filename = signatureToFilename(signature);
|
|
1232
1301
|
const baselineImagePath = safePath(this.baselinePath, `${filename}.png`);
|
|
1233
1302
|
try {
|
|
@@ -1291,7 +1360,7 @@ export class TddService {
|
|
|
1291
1360
|
}
|
|
1292
1361
|
|
|
1293
1362
|
// Generate signature for this screenshot
|
|
1294
|
-
let signature = generateScreenshotSignature(name, properties || {});
|
|
1363
|
+
let signature = generateScreenshotSignature(name, properties || {}, this.signatureProperties);
|
|
1295
1364
|
|
|
1296
1365
|
// Add screenshot to baseline metadata
|
|
1297
1366
|
const screenshotEntry = {
|
|
@@ -1348,7 +1417,7 @@ export class TddService {
|
|
|
1348
1417
|
}
|
|
1349
1418
|
|
|
1350
1419
|
// Generate signature for this screenshot
|
|
1351
|
-
let signature = generateScreenshotSignature(name, properties || {});
|
|
1420
|
+
let signature = generateScreenshotSignature(name, properties || {}, this.signatureProperties);
|
|
1352
1421
|
|
|
1353
1422
|
// Add screenshot to baseline metadata
|
|
1354
1423
|
const screenshotEntry = {
|
|
@@ -1403,7 +1472,7 @@ export class TddService {
|
|
|
1403
1472
|
}
|
|
1404
1473
|
const sanitizedName = comparison.name;
|
|
1405
1474
|
let properties = comparison.properties || {};
|
|
1406
|
-
let signature = generateScreenshotSignature(sanitizedName, properties);
|
|
1475
|
+
let signature = generateScreenshotSignature(sanitizedName, properties, this.signatureProperties);
|
|
1407
1476
|
let filename = signatureToFilename(signature);
|
|
1408
1477
|
|
|
1409
1478
|
// Find the current screenshot file
|
|
@@ -112,8 +112,25 @@ export class TestRunner extends EventEmitter {
|
|
|
112
112
|
// Error in setup phase
|
|
113
113
|
testError = error;
|
|
114
114
|
testSuccess = false;
|
|
115
|
-
}
|
|
116
|
-
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Get TDD results before stopping the server (comparisons, screenshot count)
|
|
118
|
+
let tddResults = null;
|
|
119
|
+
if (tdd) {
|
|
120
|
+
try {
|
|
121
|
+
tddResults = await this.serverManager.getTddResults();
|
|
122
|
+
if (tddResults) {
|
|
123
|
+
screenshotCount = tddResults.total || 0;
|
|
124
|
+
}
|
|
125
|
+
} catch (tddError) {
|
|
126
|
+
output.debug('tdd', 'failed to get results', {
|
|
127
|
+
error: tddError.message
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Always finalize the build and stop the server (cleanup phase)
|
|
133
|
+
try {
|
|
117
134
|
const executionTime = Date.now() - startTime;
|
|
118
135
|
if (buildId) {
|
|
119
136
|
try {
|
|
@@ -127,6 +144,8 @@ export class TestRunner extends EventEmitter {
|
|
|
127
144
|
if (!tdd && this.serverManager.server?.getScreenshotCount) {
|
|
128
145
|
screenshotCount = this.serverManager.server.getScreenshotCount(buildId) || 0;
|
|
129
146
|
}
|
|
147
|
+
} finally {
|
|
148
|
+
// Always stop the server, even if finalization fails
|
|
130
149
|
try {
|
|
131
150
|
await this.serverManager.stop();
|
|
132
151
|
} catch (stopError) {
|
|
@@ -144,7 +163,9 @@ export class TestRunner extends EventEmitter {
|
|
|
144
163
|
url: buildUrl,
|
|
145
164
|
testsPassed: testSuccess ? 1 : 0,
|
|
146
165
|
testsFailed: testSuccess ? 0 : 1,
|
|
147
|
-
screenshotsCaptured: screenshotCount
|
|
166
|
+
screenshotsCaptured: screenshotCount,
|
|
167
|
+
comparisons: tddResults?.comparisons || null,
|
|
168
|
+
failed: (tddResults?.failed || 0) > 0
|
|
148
169
|
};
|
|
149
170
|
}
|
|
150
171
|
async createBuild(options, tdd) {
|
|
@@ -2,7 +2,7 @@ import { cosmiconfigSync } from 'cosmiconfig';
|
|
|
2
2
|
import { resolve } from 'path';
|
|
3
3
|
import { getApiToken, getApiUrl, getParallelId } from './environment-config.js';
|
|
4
4
|
import { validateVizzlyConfigWithDefaults } from './config-schema.js';
|
|
5
|
-
import {
|
|
5
|
+
import { getProjectMapping } from './global-config.js';
|
|
6
6
|
import * as output from './output.js';
|
|
7
7
|
let DEFAULT_CONFIG = {
|
|
8
8
|
// API Configuration
|
|
@@ -98,14 +98,6 @@ export async function loadConfig(configPath = null, cliOverrides = {}) {
|
|
|
98
98
|
}
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
-
// 3.5. Check global config for user access token (if no CLI flag)
|
|
102
|
-
if (!config.apiKey && !cliOverrides.token) {
|
|
103
|
-
let globalToken = await getAccessToken();
|
|
104
|
-
if (globalToken) {
|
|
105
|
-
config.apiKey = globalToken;
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
|
|
109
101
|
// 4. Override with environment variables (higher priority than fallbacks)
|
|
110
102
|
let envApiKey = getApiToken();
|
|
111
103
|
let envApiUrl = getApiUrl();
|
package/docs/tdd-mode.md
CHANGED
|
@@ -388,6 +388,62 @@ rm -rf .vizzly/baselines/
|
|
|
388
388
|
npx vizzly tdd run "npm test"
|
|
389
389
|
```
|
|
390
390
|
|
|
391
|
+
## Baseline Signature Properties
|
|
392
|
+
|
|
393
|
+
Vizzly matches screenshots to baselines using a **signature**:
|
|
394
|
+
|
|
395
|
+
```
|
|
396
|
+
name | viewport_width | browser
|
|
397
|
+
```
|
|
398
|
+
|
|
399
|
+
For example: `homepage|1920|chromium`
|
|
400
|
+
|
|
401
|
+
### Custom Properties vs Baseline Signature Properties
|
|
402
|
+
|
|
403
|
+
You can pass custom properties when capturing screenshots:
|
|
404
|
+
|
|
405
|
+
```javascript
|
|
406
|
+
await vizzlyScreenshot('dashboard', screenshot, {
|
|
407
|
+
theme: 'dark',
|
|
408
|
+
locale: 'en-US',
|
|
409
|
+
mobile: true
|
|
410
|
+
});
|
|
411
|
+
```
|
|
412
|
+
|
|
413
|
+
By default, these properties help you organize and filter in the dashboard—they don't affect baseline matching. Two screenshots with the same name but different `theme` values would match the same baseline.
|
|
414
|
+
|
|
415
|
+
### Making Properties Affect Matching
|
|
416
|
+
|
|
417
|
+
To create separate baselines for different variants, configure **Baseline Signature Properties** in your project settings:
|
|
418
|
+
|
|
419
|
+
1. Go to your project on [app.vizzly.dev](https://app.vizzly.dev)
|
|
420
|
+
2. Navigate to **Settings** → **Baseline Signature Properties**
|
|
421
|
+
3. Add the property names (e.g., `theme`, `mobile`)
|
|
422
|
+
|
|
423
|
+
With `theme` configured:
|
|
424
|
+
|
|
425
|
+
```
|
|
426
|
+
dashboard|1920|chromium|dark → separate baseline
|
|
427
|
+
dashboard|1920|chromium|light → separate baseline
|
|
428
|
+
```
|
|
429
|
+
|
|
430
|
+
### TDD Mode Support
|
|
431
|
+
|
|
432
|
+
When you download cloud baselines, the CLI automatically:
|
|
433
|
+
|
|
434
|
+
1. Fetches your project's baseline signature properties
|
|
435
|
+
2. Downloads baselines with variant-aware filenames
|
|
436
|
+
3. Uses matching signatures during comparison
|
|
437
|
+
|
|
438
|
+
This keeps TDD mode behavior consistent with cloud comparisons.
|
|
439
|
+
|
|
440
|
+
### Quick Reference
|
|
441
|
+
|
|
442
|
+
| Type | Purpose | Affects Matching? |
|
|
443
|
+
|------|---------|-------------------|
|
|
444
|
+
| Custom Properties | Organize and filter in dashboard | No |
|
|
445
|
+
| Baseline Signature Properties | Create separate baselines per variant | Yes |
|
|
446
|
+
|
|
391
447
|
## Advanced Usage
|
|
392
448
|
|
|
393
449
|
### Conditional TDD Mode
|