@lambdatest/smartui-cli 2.0.2 → 2.0.3

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.
package/dist/index.cjs CHANGED
@@ -9,11 +9,13 @@ var path2 = require('path');
9
9
  var fastify = require('fastify');
10
10
  var fs = require('fs');
11
11
  var winston = require('winston');
12
+ var Ajv = require('ajv');
13
+ var addErrors = require('ajv-errors');
12
14
  var FormData = require('form-data');
13
15
  var axios = require('axios');
14
16
  var child_process = require('child_process');
15
17
  var spawn = require('cross-spawn');
16
- var playwright = require('playwright');
18
+ var test = require('@playwright/test');
17
19
 
18
20
  function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
19
21
 
@@ -22,6 +24,8 @@ var chalk__default = /*#__PURE__*/_interopDefault(chalk);
22
24
  var path2__default = /*#__PURE__*/_interopDefault(path2);
23
25
  var fastify__default = /*#__PURE__*/_interopDefault(fastify);
24
26
  var fs__default = /*#__PURE__*/_interopDefault(fs);
27
+ var Ajv__default = /*#__PURE__*/_interopDefault(Ajv);
28
+ var addErrors__default = /*#__PURE__*/_interopDefault(addErrors);
25
29
  var FormData__default = /*#__PURE__*/_interopDefault(FormData);
26
30
  var axios__default = /*#__PURE__*/_interopDefault(axios);
27
31
  var spawn__default = /*#__PURE__*/_interopDefault(spawn);
@@ -80,6 +84,7 @@ var server_default = (ctx) => __async(void 0, null, function* () {
80
84
  } catch (error) {
81
85
  reply.code(500).send({ error: { message: error.message } });
82
86
  }
87
+ ctx.totalSnapshots++;
83
88
  reply.code(200).send({ data: { message: "success" } });
84
89
  }));
85
90
  yield server.listen({ port: 8080 });
@@ -164,6 +169,7 @@ var auth_default = (ctx) => {
164
169
  task.title = "Authenticated with SmartUI";
165
170
  } catch (error) {
166
171
  ctx2.log.debug(error.message);
172
+ task.output = chalk__default.default.gray(error.message);
167
173
  throw new Error("Authentication failed");
168
174
  }
169
175
  }),
@@ -181,7 +187,7 @@ var DEFAULT_WEB_STATIC_CONFIG = [
181
187
  "url": "https://example.com/"
182
188
  }
183
189
  ];
184
- var DEFAULT_WEB_CONFIG = {
190
+ var DEFAULT_CONFIG = {
185
191
  web: {
186
192
  browsers: [
187
193
  "chrome",
@@ -193,24 +199,25 @@ var DEFAULT_WEB_CONFIG = {
193
199
  [1920, 1080],
194
200
  [1366, 768],
195
201
  [360, 640]
196
- ]
202
+ ],
203
+ waitForTimeout: 1e3
197
204
  }
198
205
  };
199
- function createWebConfig(filepath) {
200
- filepath = filepath || "smartui-web.json";
206
+ function createConfig(filepath) {
207
+ filepath = filepath || ".smartui.json";
201
208
  let filetype = path2__default.default.extname(filepath);
202
209
  if (filetype != ".json") {
203
210
  console.log("Error: Config file must have .json extension");
204
211
  return;
205
212
  }
206
213
  if (fs__default.default.existsSync(filepath)) {
207
- console.log(`Error: SmartUI Web Config already exists: ${filepath}`);
208
- console.log(`To create a new file, please specify the file name like: 'smartui config:create-web webConfig.json'`);
214
+ console.log(`Error: SmartUI Config already exists: ${filepath}`);
215
+ console.log(`To create a new file, please specify the file name like: 'smartui config:create .smartui-config.json'`);
209
216
  return;
210
217
  }
211
218
  fs__default.default.mkdirSync(path2__default.default.dirname(filepath), { recursive: true });
212
- fs__default.default.writeFileSync(filepath, JSON.stringify(DEFAULT_WEB_CONFIG, null, 2) + "\n");
213
- console.log(`Created SmartUI Web Config: ${filepath}`);
219
+ fs__default.default.writeFileSync(filepath, JSON.stringify(DEFAULT_CONFIG, null, 2) + "\n");
220
+ console.log(`Created SmartUI Config: ${filepath}`);
214
221
  }
215
222
  function createWebStaticConfig(filepath) {
216
223
  filepath = filepath || "url.json";
@@ -221,7 +228,7 @@ function createWebStaticConfig(filepath) {
221
228
  }
222
229
  if (fs__default.default.existsSync(filepath)) {
223
230
  console.log(`Error: web-static config already exists: ${filepath}`);
224
- console.log(`To create a new file, please specify the file name like: 'smartui config:create-web links.json'`);
231
+ console.log(`To create a new file, please specify the file name like: 'smartui config:create-web-static links.json'`);
225
232
  return;
226
233
  }
227
234
  fs__default.default.mkdirSync(path2__default.default.dirname(filepath), { recursive: true });
@@ -230,7 +237,95 @@ function createWebStaticConfig(filepath) {
230
237
  }
231
238
 
232
239
  // package.json
233
- var version = "2.0.2";
240
+ var version = "2.0.3";
241
+ var ajv = new Ajv__default.default({ allErrors: true });
242
+ ajv.addFormat("web-url", {
243
+ type: "string",
244
+ validate: (url) => {
245
+ const urlPattern = new RegExp("^(https?:\\/\\/)?((([a-z\\d]([a-z\\d-]*[a-z\\d])*)\\.)+[a-z]{2,}|((\\d{1,3}\\.){3}\\d{1,3}))(\\:\\d+)?(\\/[-a-z\\d%_.~+]*)*(\\?[;&a-z\\d%_.~+=-]*)?(\\#[-a-z\\d_]*)?$", "i");
246
+ return urlPattern.test(url);
247
+ }
248
+ });
249
+ addErrors__default.default(ajv);
250
+ var ConfigSchema = {
251
+ type: "object",
252
+ properties: {
253
+ web: {
254
+ type: "object",
255
+ properties: {
256
+ browsers: {
257
+ type: "array",
258
+ items: { type: "string", enum: ["chrome", "firefox", "edge", "safari"] },
259
+ uniqueItems: true,
260
+ maxItems: 4,
261
+ errorMessage: "Invalid config; allowed browsers - chrome, firefox, edge, safari"
262
+ },
263
+ viewports: {
264
+ type: "array",
265
+ items: {
266
+ type: "array",
267
+ items: [
268
+ { type: "number", minimum: 320, maximum: 7680 },
269
+ { type: "number", minimum: 320, maximum: 7680 }
270
+ ],
271
+ additionalItems: false,
272
+ minItems: 2,
273
+ maxItems: 2
274
+ },
275
+ uniqueItems: true,
276
+ maxItems: 5,
277
+ errorMessage: "Invalid config; width/height must be >= 320 and <= 7680; max viewports allowed - 5"
278
+ },
279
+ waitForPageRender: {
280
+ type: "number",
281
+ minimum: 0,
282
+ maximum: 3e5,
283
+ errorMessage: "Invalid config; waitForPageRender must be > 0 and <= 300000"
284
+ },
285
+ waitForTimeout: {
286
+ type: "number",
287
+ minimum: 0,
288
+ maximum: 3e4,
289
+ errorMessage: "Invalid config; waitForTimeout must be > 0 and <= 30000"
290
+ }
291
+ },
292
+ required: ["browsers", "viewports"],
293
+ additionalProperties: false
294
+ }
295
+ },
296
+ required: ["web"],
297
+ additionalProperties: false
298
+ };
299
+ var WebStaticConfigSchema = {
300
+ type: "array",
301
+ items: {
302
+ type: "object",
303
+ properties: {
304
+ name: {
305
+ type: "string",
306
+ minLength: 1,
307
+ errorMessage: "name is mandatory and cannot be empty"
308
+ },
309
+ url: {
310
+ type: "string",
311
+ format: "web-url",
312
+ errorMessage: "url is mandatory and must be a valid web URL"
313
+ },
314
+ waitForTimeout: {
315
+ type: "number",
316
+ nullable: true,
317
+ minimum: 0,
318
+ maximum: 3e4,
319
+ errorMessage: "waitForTimeout must be > 0 and <= 30000"
320
+ }
321
+ },
322
+ required: ["name", "url"],
323
+ additionalProperties: false
324
+ },
325
+ uniqueItems: true
326
+ };
327
+ var validateConfig = ajv.compile(ConfigSchema);
328
+ var validateWebStaticConfig = ajv.compile(WebStaticConfigSchema);
234
329
  function delDir(dir) {
235
330
  if (fs__default.default.existsSync(dir)) {
236
331
  fs__default.default.rmSync(dir, { recursive: true });
@@ -286,18 +381,21 @@ var httpClient = class {
286
381
  },
287
382
  config: {
288
383
  browsers: config.browsers,
289
- resolutions: config.viewports
384
+ resolutions: config.viewports,
385
+ waitForPageRender: config.waitForPageRender,
386
+ waitForTimeout: config.waitForTimeout
290
387
  }
291
388
  }
292
389
  }, log);
293
390
  }
294
- finalizeBuild(buildId, log) {
391
+ finalizeBuild(buildId, totalSnapshots, log) {
392
+ let params = { buildId };
393
+ if (totalSnapshots > -1)
394
+ params.totalSnapshots = totalSnapshots;
295
395
  return this.request({
296
396
  url: "/build",
297
397
  method: "DELETE",
298
- params: {
299
- buildId
300
- }
398
+ params
301
399
  }, log);
302
400
  }
303
401
  uploadSnapshot(buildId, snapshot, testType, log) {
@@ -347,26 +445,34 @@ var httpClient = class {
347
445
  var ctx_default = (options) => {
348
446
  let env = env_default();
349
447
  let viewports = [];
350
- let webConfig = DEFAULT_WEB_CONFIG;
448
+ let config = DEFAULT_CONFIG;
351
449
  try {
352
450
  if (options.config) {
353
- webConfig = JSON.parse(fs__default.default.readFileSync(options.config, "utf-8"));
354
- }
355
- for (let viewport of webConfig.web.viewports) {
356
- viewports.push({ width: viewport[0], height: viewport[1] });
451
+ config = JSON.parse(fs__default.default.readFileSync(options.config, "utf-8"));
452
+ if (config.web.resolutions) {
453
+ config.web.viewports = config.web.resolutions;
454
+ delete config.web.resolutions;
455
+ }
357
456
  }
457
+ if (!validateConfig(config))
458
+ throw new Error(validateConfig.errors[0].message);
358
459
  } catch (error) {
359
- throw new Error(error.message);
460
+ console.log(`[smartui] Error: ${error.message}`);
461
+ process.exit();
360
462
  }
463
+ for (let viewport of config.web.viewports)
464
+ viewports.push({ width: viewport[0], height: viewport[1] });
361
465
  return {
362
466
  env,
363
467
  log: logger_default,
364
468
  client: new httpClient(env),
365
- config: {
366
- browsers: webConfig.web.browsers,
367
- viewports
469
+ webConfig: {
470
+ browsers: config.web.browsers,
471
+ viewports,
472
+ waitForPageRender: config.web.waitForPageRender || 0,
473
+ waitForTimeout: config.web.waitForTimeout || 0
368
474
  },
369
- staticConfig: [],
475
+ webStaticConfig: [],
370
476
  git: {
371
477
  branch: "",
372
478
  commitId: "",
@@ -382,7 +488,8 @@ var ctx_default = (options) => {
382
488
  projectId: ""
383
489
  },
384
490
  args: {},
385
- cliVersion: version
491
+ cliVersion: version,
492
+ totalSnapshots: -1
386
493
  };
387
494
  };
388
495
  function executeCommand(command3) {
@@ -445,7 +552,7 @@ var createBuild_default = (ctx) => {
445
552
  task: (ctx2, task) => __async(void 0, null, function* () {
446
553
  updateLogContext({ task: "createBuild" });
447
554
  try {
448
- let resp = yield ctx2.client.createBuild(ctx2.git, ctx2.config, ctx2.log);
555
+ let resp = yield ctx2.client.createBuild(ctx2.git, ctx2.webConfig, ctx2.log);
449
556
  ctx2.build = {
450
557
  id: resp.data.buildId,
451
558
  name: resp.data.buildName,
@@ -495,21 +602,22 @@ var exec_default = (ctx) => {
495
602
  }));
496
603
  });
497
604
  }),
498
- rendererOptions: { persistentOutput: true }
605
+ rendererOptions: { persistentOutput: true },
606
+ exitOnError: false
499
607
  };
500
608
  };
501
- var finalizeBuild_default = (ctx, waitTime) => {
609
+ var finalizeBuild_default = (ctx) => {
502
610
  return {
503
611
  title: `Finalizing build`,
504
612
  task: (ctx2, task) => __async(void 0, null, function* () {
505
613
  try {
506
- if (waitTime > 0) {
507
- yield new Promise((resolve) => setTimeout(resolve, waitTime));
508
- yield ctx2.client.finalizeBuild(ctx2.build.id, ctx2.log);
509
- }
614
+ yield new Promise((resolve) => setTimeout(resolve, 2e3));
615
+ yield ctx2.client.finalizeBuild(ctx2.build.id, ctx2.totalSnapshots, ctx2.log);
510
616
  task.output = chalk__default.default.gray(`build url: ${ctx2.build.url}`);
511
617
  task.title = "Finalized build";
512
618
  } catch (error) {
619
+ ctx2.log.debug(error.message);
620
+ task.output = chalk__default.default.gray(error.message);
513
621
  throw new Error("Finalize build error");
514
622
  }
515
623
  }),
@@ -528,6 +636,7 @@ command.name("exec").description("Run test commands around SmartUI").argument("<
528
636
  return;
529
637
  }
530
638
  ctx.args.execCommand = execCommand;
639
+ ctx.totalSnapshots = 0;
531
640
  let tasks = new listr2.Listr(
532
641
  [
533
642
  auth_default(),
@@ -535,7 +644,7 @@ command.name("exec").description("Run test commands around SmartUI").argument("<
535
644
  getGitInfo_default(),
536
645
  createBuild_default(),
537
646
  exec_default(ctx),
538
- finalizeBuild_default(ctx, 3e4)
647
+ finalizeBuild_default()
539
648
  ],
540
649
  {
541
650
  rendererOptions: {
@@ -560,12 +669,12 @@ command.name("exec").description("Run test commands around SmartUI").argument("<
560
669
  var exec_default2 = command;
561
670
  var configWeb = new commander.Command();
562
671
  var configStatic = new commander.Command();
563
- configWeb.name("config:create-web").description("Create SmartUI Web config file").argument("[filepath]", "Optional config filepath").action(function(filepath, options) {
672
+ configWeb.name("config:create").description("Create SmartUI config file").argument("[filepath]", "Optional config filepath").action(function(filepath, options) {
564
673
  return __async(this, null, function* () {
565
- createWebConfig(filepath);
674
+ createConfig(filepath);
566
675
  });
567
676
  });
568
- configStatic.name("config:web-static").description("Create Web Static config file").argument("[filepath]", "Optional config filepath").action(function(filepath, options) {
677
+ configStatic.name("config:create-web-static").description("Create Web Static config file").argument("[filepath]", "Optional config filepath").action(function(filepath, options) {
569
678
  return __async(this, null, function* () {
570
679
  createWebStaticConfig(filepath);
571
680
  });
@@ -575,51 +684,60 @@ var BROWSER_SAFARI = "safari";
575
684
  var BROWSER_FIREFOX = "firefox";
576
685
  var BROWSER_EDGE = "edge";
577
686
  var EDGE_CHANNEL = "msedge";
687
+ var PW_WEBKIT = "webkit";
578
688
  function captureScreenshots(ctx, screenshots) {
579
689
  return __async(this, null, function* () {
580
690
  var _a;
581
691
  delDir("screenshots");
582
- let totalBrowsers = ctx.config.browsers.length;
583
- let totalViewports = ctx.config.viewports.length;
692
+ let totalBrowsers = ctx.webConfig.browsers.length;
693
+ let totalViewports = ctx.webConfig.viewports.length;
584
694
  let totalScreenshots = screenshots.length;
585
695
  for (let i = 0; i < totalBrowsers; i++) {
586
- let browserName = (_a = ctx.config.browsers[i]) == null ? void 0 : _a.toLowerCase();
696
+ let browserName = (_a = ctx.webConfig.browsers[i]) == null ? void 0 : _a.toLowerCase();
587
697
  let browser;
588
698
  let launchOptions = { headless: true };
589
- switch (browserName) {
590
- case BROWSER_CHROME:
591
- browser = yield playwright.chromium.launch(launchOptions);
592
- break;
593
- case BROWSER_SAFARI:
594
- browser = yield playwright.webkit.launch(launchOptions);
595
- break;
596
- case BROWSER_FIREFOX:
597
- browser = yield playwright.firefox.launch(launchOptions);
598
- break;
599
- case BROWSER_EDGE:
600
- launchOptions.channel = EDGE_CHANNEL;
601
- browser = yield playwright.chromium.launch(launchOptions);
602
- break;
603
- }
604
- const context = yield browser.newContext();
605
- for (let j = 0; j < totalScreenshots; j++) {
606
- let screenshot = screenshots[j];
607
- let screenshotId = screenshot.name.toLowerCase().replace(/\s/g, "-");
608
- const page = yield context.newPage();
609
- yield page.goto(screenshot.url);
610
- yield page.waitForTimeout(screenshot.waitForTimeout || 0);
611
- for (let k = 0; k < totalViewports; k++) {
612
- let { width, height } = ctx.config.viewports[k];
613
- let ssName = `${browserName}-${width}x${height}-${screenshotId}.png`;
614
- let ssPath = `screenshots/${screenshotId}/${ssName}.png`;
615
- yield page.setViewportSize({ width, height });
616
- yield page.screenshot({ path: ssPath, fullPage: true });
617
- let completed = i == totalBrowsers - 1 && j == totalScreenshots - 1 && k == totalViewports - 1 ? true : false;
618
- ctx.client.uploadScreenshot(ctx.build, ssPath, screenshot.name, browserName, `${width}x${height}`, completed);
699
+ let pageOptions = { waitUntil: process.env.SMARTUI_PAGE_WAIT_UNTIL_EVENT || "load" };
700
+ try {
701
+ switch (browserName) {
702
+ case BROWSER_CHROME:
703
+ browser = yield test.chromium.launch(launchOptions);
704
+ break;
705
+ case BROWSER_SAFARI:
706
+ browser = yield test.webkit.launch(launchOptions);
707
+ break;
708
+ case BROWSER_FIREFOX:
709
+ browser = yield test.firefox.launch(launchOptions);
710
+ break;
711
+ case BROWSER_EDGE:
712
+ launchOptions.channel = EDGE_CHANNEL;
713
+ browser = yield test.chromium.launch(launchOptions);
714
+ break;
715
+ }
716
+ const context = yield browser.newContext();
717
+ for (let j = 0; j < totalScreenshots; j++) {
718
+ let screenshot = screenshots[j];
719
+ let screenshotId = screenshot.name.toLowerCase().replace(/\s/g, "-");
720
+ const page = yield context.newPage();
721
+ yield page.goto(screenshot.url, pageOptions);
722
+ yield page.waitForTimeout(screenshot.waitForTimeout || 0);
723
+ for (let k = 0; k < totalViewports; k++) {
724
+ let { width, height } = ctx.webConfig.viewports[k];
725
+ let ssName = `${browserName}-${width}x${height}-${screenshotId}.png`;
726
+ let ssPath = `screenshots/${screenshotId}/${ssName}.png`;
727
+ yield page.setViewportSize({ width, height });
728
+ yield page.screenshot({ path: ssPath });
729
+ let completed = i == totalBrowsers - 1 && j == totalScreenshots - 1 && k == totalViewports - 1 ? true : false;
730
+ browserName = browserName === BROWSER_SAFARI ? PW_WEBKIT : browserName;
731
+ ctx.client.uploadScreenshot(ctx.build, ssPath, screenshot.name, browserName, `${width}x${height}`, completed);
732
+ }
733
+ yield page.close();
619
734
  }
620
- yield page.close();
735
+ yield browser.close();
736
+ } catch (error) {
737
+ if (browser)
738
+ yield browser.close();
739
+ throw error;
621
740
  }
622
- yield browser.close();
623
741
  }
624
742
  return totalBrowsers * totalViewports * totalScreenshots;
625
743
  });
@@ -629,16 +747,17 @@ var captureScreenshots_default = (ctx) => {
629
747
  title: "Capturing screenshots",
630
748
  task: (ctx2, task) => __async(void 0, null, function* () {
631
749
  try {
632
- let { staticConfig: screenshots } = ctx2;
750
+ let { webStaticConfig: screenshots } = ctx2;
633
751
  let totalScreenshots = yield captureScreenshots(ctx2, screenshots);
634
752
  task.title = "Screenshots captured successfully";
635
753
  task.output = chalk__default.default.gray(`total screenshots: ${totalScreenshots}`);
636
754
  } catch (error) {
637
- console.error(error);
755
+ task.output = chalk__default.default.gray(`${error.message}`);
638
756
  throw new Error("Capturing screenshots failed");
639
757
  }
640
758
  }),
641
- rendererOptions: { persistentOutput: true }
759
+ rendererOptions: { persistentOutput: true },
760
+ exitOnError: false
642
761
  };
643
762
  };
644
763
 
@@ -647,13 +766,25 @@ var command2 = new commander.Command();
647
766
  command2.name("capture").description("Capture screenshots of static sites").argument("<file>", "Web static config file").action(function(file, _, command3) {
648
767
  return __async(this, null, function* () {
649
768
  let ctx = ctx_default(command3.optsWithGlobals());
769
+ if (!fs__default.default.existsSync(file)) {
770
+ console.log(`Error: Web Static Config file ${file} not found.`);
771
+ return;
772
+ }
773
+ try {
774
+ ctx.webStaticConfig = JSON.parse(fs__default.default.readFileSync(file, "utf8"));
775
+ if (!validateWebStaticConfig(ctx.webStaticConfig))
776
+ throw new Error(validateWebStaticConfig.errors[0].message);
777
+ } catch (error) {
778
+ console.log(`[smartui] Error: Invalid Web Static Config; ${error.message}`);
779
+ return;
780
+ }
650
781
  let tasks = new listr2.Listr(
651
782
  [
652
783
  auth_default(),
653
784
  getGitInfo_default(),
654
785
  createBuild_default(),
655
786
  captureScreenshots_default(),
656
- finalizeBuild_default(ctx, 0)
787
+ finalizeBuild_default()
657
788
  ],
658
789
  {
659
790
  rendererOptions: {
@@ -667,11 +798,6 @@ command2.name("capture").description("Capture screenshots of static sites").argu
667
798
  }
668
799
  );
669
800
  try {
670
- if (!fs__default.default.existsSync(file)) {
671
- console.log(`Error: Config file ${file} not found.`);
672
- return;
673
- }
674
- ctx.staticConfig = JSON.parse(fs__default.default.readFileSync(file, "utf8"));
675
801
  yield tasks.run(ctx);
676
802
  } catch (error) {
677
803
  console.log("\nRefer docs: https://www.lambdatest.com/support/docs/smart-visual-regression-testing/");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lambdatest/smartui-cli",
3
- "version": "2.0.2",
3
+ "version": "2.0.3",
4
4
  "description": "A command line interface (CLI) to run SmartUI tests on LambdaTest",
5
5
  "files": [
6
6
  "dist/**/*"
@@ -17,9 +17,15 @@
17
17
  "author": "LambdaTest <keys@lambdatest.com>",
18
18
  "license": "MIT",
19
19
  "dependencies": {
20
+ "@playwright/browser-chromium": "^1.40.1",
21
+ "@playwright/browser-firefox": "^1.40.1",
22
+ "@playwright/browser-webkit": "^1.40.1",
23
+ "@playwright/test": "^1.40.1",
20
24
  "@types/cross-spawn": "^6.0.4",
21
25
  "@types/node": "^20.8.9",
22
26
  "@types/which": "^3.0.2",
27
+ "ajv": "^8.12.0",
28
+ "ajv-errors": "^3.0.0",
23
29
  "axios": "^1.6.0",
24
30
  "chalk": "^4.1.2",
25
31
  "commander": "^11.1.0",
@@ -27,7 +33,6 @@
27
33
  "fastify": "^4.24.3",
28
34
  "form-data": "^4.0.0",
29
35
  "listr2": "^7.0.1",
30
- "playwright": "^1.39.0",
31
36
  "tsup": "^7.2.0",
32
37
  "which": "^4.0.0",
33
38
  "winston": "^3.10.0"