@lvce-editor/test-with-playwright 18.0.0 → 19.0.0

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 (3) hide show
  1. package/README.md +21 -1
  2. package/dist/main.js +496 -13
  3. package/package.json +3 -3
package/README.md CHANGED
@@ -5,7 +5,15 @@
5
5
  ```json
6
6
  {
7
7
  "scripts": {
8
- "e2e": "node ./node_modules/@lvce-editor/test-with-playwright/bin/test-with-playwright.js --only-extension=. --test-path=./e2e"
8
+ "e2e": "node ./node_modules/@lvce-editor/test-with-playwright/bin/test-with-playwright.js --runtime=browser --only-extension=. --test-path=./e2e"
9
+ }
10
+ }
11
+ ```
12
+
13
+ ```json
14
+ {
15
+ "scripts": {
16
+ "e2e:electron": "node ./node_modules/@lvce-editor/test-with-playwright/bin/test-with-playwright.js --runtime=electron --electron-version=v0.84.0 --test-path=./e2e"
9
17
  }
10
18
  }
11
19
  ```
@@ -19,6 +27,7 @@ test('sample.hello-world', async () => {
19
27
 
20
28
  ## CLI Flags
21
29
 
30
+ - `--runtime`: `browser` (default) or `electron`
22
31
  - `--only-extension`: path to the extension under test
23
32
  - `--test-path`: path to the test root
24
33
  - `--server-path`: explicit server entry point
@@ -26,6 +35,17 @@ test('sample.hello-world', async () => {
26
35
  - `--headless`: run Playwright in headless mode
27
36
  - `--browser`: browser engine to launch, either `chromium` or `firefox`
28
37
  - `--trace-focus`: add `traceFocus=true` to test URLs
38
+ - `--electron-path`: path to an existing Electron app executable
39
+ - `--electron-version`: Lvce release version to download, required for `--runtime=electron` when `--electron-path` is not provided
40
+ - `--electron-cache-dir`: directory for downloaded Electron apps, defaults to `.test-with-playwright/electron`
41
+ - `--electron-arg`: extra Electron app argument, can be provided multiple times
42
+ - `--electron-env`: Electron environment variable in `NAME=value` form, can be provided multiple times
43
+
44
+ ## Runtime Notes
45
+
46
+ - `browser` keeps the server-backed HTML test execution flow.
47
+ - `electron` downloads or reuses a Lvce Electron app, launches it with Playwright, and runs each test module against the first window.
48
+ - `--electron-path` skips downloading and is useful for custom local builds.
29
49
 
30
50
  ## Environment Variables
31
51
 
package/dist/main.js CHANGED
@@ -1,4 +1,10 @@
1
- import { join } from 'node:path';
1
+ import { dirname, join, resolve as resolve$1, basename } from 'node:path';
2
+ import { createWriteStream, existsSync } from 'node:fs';
3
+ import { mkdir, readdir, stat, readFile, chmod, writeFile, rm, mkdtemp } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import { pipeline } from 'node:stream/promises';
6
+ import { execFile as execFile$1 } from 'node:child_process';
7
+ import { promisify } from 'node:util';
2
8
  import { pathToFileURL, fileURLToPath } from 'node:url';
3
9
 
4
10
  const HandleResult = 'HandleResult';
@@ -110,6 +116,30 @@ const commandMap = {
110
116
  [HandleResult]: handleResult
111
117
  };
112
118
 
119
+ const getHelpMessage = () => {
120
+ return `Usage: test-with-playwright [options]
121
+
122
+ Options:
123
+ --browser=<browser> Browser to run tests in: chromium or firefox
124
+ --runtime=<runtime> Runtime to run tests in: browser or electron
125
+ --filter=<pattern> Only run tests matching the filter
126
+ --headless Run the browser in headless mode
127
+ --only-extension=<path> Path to the extension under test
128
+ --server-path=<path> Path to the editor server entrypoint
129
+ --test-path=<path> Path to the test files
130
+ --trace-focus Log focus changes while tests run
131
+ --electron-path=<path> Path to an existing Electron app executable
132
+ --electron-version=<ver> Lvce release version to download, for example v0.84.0
133
+ --electron-cache-dir=<p> Directory for downloaded Electron apps
134
+ --electron-arg=<arg> Extra Electron app argument, repeatable
135
+ --electron-env=<env> Electron environment variable as NAME=value, repeatable
136
+ -h, --help Show this help message
137
+
138
+ Environment:
139
+ ONLY_EXTENSION Default extension path
140
+ TEST_PATH Default test path`;
141
+ };
142
+
113
143
  function getDefaultExportFromCjs (x) {
114
144
  return x && x.__esModule && Object.prototype.hasOwnProperty.call(x, 'default') ? x['default'] : x;
115
145
  }
@@ -347,26 +377,67 @@ function requireMinimist () {
347
377
  var minimistExports = requireMinimist();
348
378
  const parseArgv = /*@__PURE__*/getDefaultExportFromCjs(minimistExports);
349
379
 
380
+ const toCliString = value => {
381
+ if (typeof value === 'string') {
382
+ return value;
383
+ }
384
+ if (typeof value === 'number' || typeof value === 'boolean') {
385
+ return String(value);
386
+ }
387
+ throw new TypeError('expected cli argument value to be a string, number, or boolean');
388
+ };
389
+ const toStringArray = value => {
390
+ if (value === undefined) {
391
+ return undefined;
392
+ }
393
+ if (Array.isArray(value)) {
394
+ return value.map(toCliString);
395
+ }
396
+ return [toCliString(value)];
397
+ };
350
398
  const parseCliArgs = argv => {
351
399
  const parsed = parseArgv(argv);
352
400
  const result = Object.create(null);
353
401
  if (parsed.browser) {
354
- result.browser = parsed.browser;
402
+ result.browser = String(parsed.browser);
403
+ }
404
+ if (parsed.runtime) {
405
+ result.runtime = String(parsed.runtime);
355
406
  }
356
407
  if (parsed.filter) {
357
- result.filter = parsed.filter;
408
+ result.filter = String(parsed.filter);
409
+ }
410
+ if (parsed.help || parsed.h) {
411
+ result.help = true;
358
412
  }
359
413
  if (parsed.headless) {
360
414
  result.headless = true;
361
415
  }
362
416
  if (parsed['only-extension']) {
363
- result.onlyExtension = parsed['only-extension'];
417
+ result.onlyExtension = String(parsed['only-extension']);
364
418
  }
365
419
  if (parsed['test-path']) {
366
- result.testPath = parsed['test-path'];
420
+ result.testPath = String(parsed['test-path']);
367
421
  }
368
422
  if (parsed['server-path']) {
369
- result.serverPath = parsed['server-path'];
423
+ result.serverPath = String(parsed['server-path']);
424
+ }
425
+ if (parsed['electron-path']) {
426
+ result.electronPath = String(parsed['electron-path']);
427
+ }
428
+ if (parsed['electron-version']) {
429
+ result.electronVersion = String(parsed['electron-version']);
430
+ }
431
+ if (parsed['electron-cache-dir']) {
432
+ result.electronCacheDir = String(parsed['electron-cache-dir']);
433
+ }
434
+ const electronArgs = toStringArray(parsed['electron-arg']);
435
+ if (electronArgs) {
436
+ result.electronArgs = electronArgs;
437
+ }
438
+ const electronEnv = toStringArray(parsed['electron-env']);
439
+ if (electronEnv) {
440
+ result.electronEnv = electronEnv;
370
441
  }
371
442
  if (parsed['trace-focus']) {
372
443
  result.traceFocus = true;
@@ -389,26 +460,405 @@ const defaultOptions = {
389
460
  browser: 'chromium',
390
461
  extensionPath: '',
391
462
  headless: false,
463
+ help: false,
464
+ runtime: 'browser',
392
465
  testPath: ''
393
466
  };
394
467
  const isBrowser = value => {
395
468
  return value === 'chromium' || value === 'firefox';
396
469
  };
470
+ const isRuntime = value => {
471
+ return value === 'browser' || value === 'electron';
472
+ };
397
473
  const getOptions = ({
398
474
  argv,
399
475
  env
400
476
  }) => {
401
477
  const parsedEnv = parseEnv(env);
402
478
  const parsedArgs = parseCliArgs(argv);
403
- const browser = parsedArgs.browser ?? defaultOptions.browser;
404
- if (!isBrowser(browser)) {
405
- throw new Error(`[test-with-playwright] unsupported browser: ${browser}`);
479
+ if (parsedArgs.browser !== undefined && !isBrowser(parsedArgs.browser)) {
480
+ throw new Error(`[test-with-playwright] unsupported browser: ${parsedArgs.browser}`);
406
481
  }
482
+ if (parsedArgs.runtime !== undefined && !isRuntime(parsedArgs.runtime)) {
483
+ throw new Error(`[test-with-playwright] unsupported runtime: ${parsedArgs.runtime}`);
484
+ }
485
+ const browser = parsedArgs.browser ?? defaultOptions.browser;
486
+ const runtime = parsedArgs.runtime ?? defaultOptions.runtime;
407
487
  return {
408
488
  ...defaultOptions,
409
489
  ...parsedEnv,
410
490
  ...parsedArgs,
411
- browser
491
+ browser,
492
+ runtime
493
+ };
494
+ };
495
+
496
+ const downloadFile = async ({
497
+ to,
498
+ url
499
+ }) => {
500
+ await mkdir(dirname(to), {
501
+ recursive: true
502
+ });
503
+ const response = await fetch(url);
504
+ if (!response.ok || !response.body) {
505
+ throw new Error(`[test-with-playwright] failed to download ${url}: ${response.status} ${response.statusText}`);
506
+ }
507
+ await pipeline(response.body, createWriteStream(to));
508
+ };
509
+
510
+ const execFileAsync = promisify(execFile$1);
511
+ const execFile = async ({
512
+ args,
513
+ command
514
+ }) => {
515
+ await execFileAsync(command, [...args], {
516
+ windowsHide: true
517
+ });
518
+ };
519
+
520
+ const isWindowsExecutable = entry => {
521
+ return entry.toLowerCase().endsWith('.exe') && entry.toLowerCase().includes('lvce');
522
+ };
523
+ const isMacosExecutable = entry => {
524
+ return entry.endsWith('.app');
525
+ };
526
+ const findFile = async (directory, predicate) => {
527
+ const entries = await readdir(directory, {
528
+ withFileTypes: true
529
+ });
530
+ for (const entry of entries) {
531
+ const absolutePath = join(directory, entry.name);
532
+ if (entry.isDirectory()) {
533
+ const nestedResult = await findFile(absolutePath, predicate);
534
+ if (nestedResult) {
535
+ return nestedResult;
536
+ }
537
+ continue;
538
+ }
539
+ if (entry.isFile() && predicate(entry.name)) {
540
+ return absolutePath;
541
+ }
542
+ }
543
+ return undefined;
544
+ };
545
+ const findApp = async directory => {
546
+ const entries = await readdir(directory, {
547
+ withFileTypes: true
548
+ });
549
+ for (const entry of entries) {
550
+ const absolutePath = join(directory, entry.name);
551
+ if (entry.isDirectory() && isMacosExecutable(entry.name)) {
552
+ return absolutePath;
553
+ }
554
+ if (entry.isDirectory()) {
555
+ const nestedResult = await findApp(absolutePath);
556
+ if (nestedResult) {
557
+ return nestedResult;
558
+ }
559
+ }
560
+ }
561
+ return undefined;
562
+ };
563
+ const findMacosExecutable = async directory => {
564
+ const app = await findApp(directory);
565
+ if (!app) {
566
+ return undefined;
567
+ }
568
+ const macosDirectory = join(app, 'Contents', 'MacOS');
569
+ const entries = await readdir(macosDirectory);
570
+ for (const entry of entries) {
571
+ const absolutePath = join(macosDirectory, entry);
572
+ const fileStat = await stat(absolutePath);
573
+ if (fileStat.isFile()) {
574
+ return absolutePath;
575
+ }
576
+ }
577
+ return undefined;
578
+ };
579
+ const findElectronExecutable = async ({
580
+ directory,
581
+ platform
582
+ }) => {
583
+ if (platform === 'darwin') {
584
+ const executable = await findMacosExecutable(directory);
585
+ if (executable) {
586
+ return executable;
587
+ }
588
+ } else if (platform === 'win32') {
589
+ const executable = await findFile(directory, isWindowsExecutable);
590
+ if (executable) {
591
+ return executable;
592
+ }
593
+ } else {
594
+ const executable = await findFile(directory, entry => entry === 'lvce');
595
+ if (executable) {
596
+ return executable;
597
+ }
598
+ }
599
+ throw new Error(`[test-with-playwright] failed to locate Electron executable in ${directory}`);
600
+ };
601
+
602
+ const getLinuxArch = arch => {
603
+ switch (arch) {
604
+ case 'arm':
605
+ return 'armhf';
606
+ case 'arm64':
607
+ return 'arm64';
608
+ case 'x64':
609
+ return 'amd64';
610
+ default:
611
+ throw new Error(`[test-with-playwright] unsupported electron platform: linux/${arch}`);
612
+ }
613
+ };
614
+ const getWindowsArch = arch => {
615
+ switch (arch) {
616
+ case 'arm64':
617
+ return 'arm64';
618
+ case 'x64':
619
+ return 'x64';
620
+ default:
621
+ throw new Error(`[test-with-playwright] unsupported electron platform: win32/${arch}`);
622
+ }
623
+ };
624
+ const resolveElectronAsset = ({
625
+ arch = process.arch,
626
+ platform = process.platform,
627
+ version
628
+ }) => {
629
+ switch (platform) {
630
+ case 'darwin':
631
+ if (arch !== 'arm64') {
632
+ throw new Error(`[test-with-playwright] unsupported electron platform: darwin/${arch}`);
633
+ }
634
+ return {
635
+ archiveType: 'dmg',
636
+ assetName: `lvce-${version}_arm64.dmg`
637
+ };
638
+ case 'linux':
639
+ return {
640
+ archiveType: 'deb',
641
+ assetName: `lvce-${version}_${getLinuxArch(arch)}.deb`
642
+ };
643
+ case 'win32':
644
+ return {
645
+ archiveType: 'exe',
646
+ assetName: `Lvce-Setup-${version}-${getWindowsArch(arch)}.exe`
647
+ };
648
+ default:
649
+ throw new Error(`[test-with-playwright] unsupported electron platform: ${platform}/${arch}`);
650
+ }
651
+ };
652
+
653
+ const getDownloadUrl = ({
654
+ assetName,
655
+ version
656
+ }) => {
657
+ return `https://github.com/lvce-editor/lvce-editor/releases/download/${version}/${assetName}`;
658
+ };
659
+ const prepareLinux = async ({
660
+ assetPath,
661
+ cachePath
662
+ }) => {
663
+ await execFile({
664
+ args: ['-x', assetPath, cachePath],
665
+ command: 'dpkg-deb'
666
+ });
667
+ };
668
+ const prepareMacos = async ({
669
+ assetPath,
670
+ cachePath
671
+ }) => {
672
+ const mountRoot = await mkdtemp(join(tmpdir(), 'test-with-playwright-dmg-'));
673
+ try {
674
+ await execFile({
675
+ args: ['attach', assetPath, '-mountpoint', mountRoot, '-nobrowse', '-quiet'],
676
+ command: 'hdiutil'
677
+ });
678
+ const entries = await readdir(mountRoot);
679
+ const appName = entries.find(entry => entry.endsWith('.app'));
680
+ if (!appName) {
681
+ throw new Error(`[test-with-playwright] failed to locate .app in ${assetPath}`);
682
+ }
683
+ await execFile({
684
+ args: ['-R', join(mountRoot, appName), join(cachePath, appName)],
685
+ command: 'cp'
686
+ });
687
+ } finally {
688
+ try {
689
+ await execFile({
690
+ args: ['detach', mountRoot, '-quiet'],
691
+ command: 'hdiutil'
692
+ });
693
+ } catch {
694
+ // ignore detach errors during cleanup
695
+ }
696
+ await rm(mountRoot, {
697
+ force: true,
698
+ recursive: true
699
+ });
700
+ }
701
+ };
702
+ const prepareWindows = async ({
703
+ assetPath,
704
+ cachePath
705
+ }) => {
706
+ await execFile({
707
+ args: ['/S', `/D=${cachePath}`],
708
+ command: assetPath
709
+ });
710
+ };
711
+ const prepareArchive = async ({
712
+ archiveType,
713
+ assetPath,
714
+ cachePath
715
+ }) => {
716
+ await rm(cachePath, {
717
+ force: true,
718
+ recursive: true
719
+ });
720
+ await mkdir(cachePath, {
721
+ recursive: true
722
+ });
723
+ switch (archiveType) {
724
+ case 'deb':
725
+ await prepareLinux({
726
+ assetPath,
727
+ cachePath
728
+ });
729
+ break;
730
+ case 'dmg':
731
+ await prepareMacos({
732
+ assetPath,
733
+ cachePath
734
+ });
735
+ break;
736
+ case 'exe':
737
+ await prepareWindows({
738
+ assetPath,
739
+ cachePath
740
+ });
741
+ break;
742
+ default:
743
+ throw new Error(`[test-with-playwright] unsupported Electron archive type: ${archiveType}`);
744
+ }
745
+ };
746
+ const prepareElectronApp = async ({
747
+ cacheDir,
748
+ electronPath,
749
+ version
750
+ }) => {
751
+ if (electronPath) {
752
+ return resolve$1(electronPath);
753
+ }
754
+ if (!version) {
755
+ throw new Error('[test-with-playwright] --electron-version is required when --runtime=electron and --electron-path is not provided');
756
+ }
757
+ const {
758
+ archiveType,
759
+ assetName
760
+ } = resolveElectronAsset({
761
+ version
762
+ });
763
+ const cachePath = resolve$1(cacheDir, version, process.platform, process.arch);
764
+ const downloadsPath = join(cachePath, 'downloads');
765
+ const appPath = join(cachePath, 'app');
766
+ const executableMarkerPath = join(cachePath, 'executable-path.txt');
767
+ if (existsSync(executableMarkerPath)) {
768
+ const cachedExecutablePathContent = await readFile(executableMarkerPath, 'utf8');
769
+ const cachedExecutablePath = cachedExecutablePathContent.trim();
770
+ if (existsSync(cachedExecutablePath)) {
771
+ return cachedExecutablePath;
772
+ }
773
+ }
774
+ const assetPath = join(downloadsPath, basename(assetName));
775
+ await mkdir(downloadsPath, {
776
+ recursive: true
777
+ });
778
+ if (!existsSync(assetPath)) {
779
+ await downloadFile({
780
+ to: assetPath,
781
+ url: getDownloadUrl({
782
+ assetName,
783
+ version
784
+ })
785
+ });
786
+ }
787
+ await prepareArchive({
788
+ archiveType,
789
+ assetPath,
790
+ cachePath: appPath
791
+ });
792
+ const executablePath = await findElectronExecutable({
793
+ directory: appPath,
794
+ platform: process.platform
795
+ });
796
+ if (process.platform !== 'win32') {
797
+ await chmod(executablePath, 0o755);
798
+ }
799
+ await writeFile(executableMarkerPath, executablePath);
800
+ return executablePath;
801
+ };
802
+
803
+ const parseElectronEnv = electronEnv => {
804
+ const env = Object.create(null);
805
+ if (!electronEnv) {
806
+ return env;
807
+ }
808
+ for (const item of electronEnv) {
809
+ const index = item.indexOf('=');
810
+ if (index === -1) {
811
+ env[item] = '';
812
+ continue;
813
+ }
814
+ const key = item.slice(0, index);
815
+ const value = item.slice(index + 1);
816
+ env[key] = value;
817
+ }
818
+ return env;
819
+ };
820
+ const getBrowserRuntimeOptions = serverPath => {
821
+ if (serverPath) {
822
+ return {
823
+ serverPath,
824
+ type: 'browser'
825
+ };
826
+ }
827
+ return {
828
+ type: 'browser'
829
+ };
830
+ };
831
+ const getRuntimeOptions = async ({
832
+ cwd,
833
+ electronArgs,
834
+ electronCacheDir,
835
+ electronEnv,
836
+ electronPath,
837
+ electronVersion,
838
+ runtime,
839
+ serverPath
840
+ }) => {
841
+ if (runtime !== undefined && runtime !== 'browser' && runtime !== 'electron') {
842
+ throw new Error(`[test-with-playwright] unsupported runtime: ${runtime}`);
843
+ }
844
+ if (runtime !== 'electron') {
845
+ return getBrowserRuntimeOptions(serverPath);
846
+ }
847
+ const cacheDir = resolve$1(cwd, electronCacheDir || '.test-with-playwright/electron');
848
+ const executablePath = await prepareElectronApp({
849
+ cacheDir,
850
+ ...(electronPath && {
851
+ electronPath: resolve$1(cwd, electronPath)
852
+ }),
853
+ ...(electronVersion && {
854
+ version: electronVersion
855
+ })
856
+ });
857
+ return {
858
+ args: electronArgs || [],
859
+ env: parseElectronEnv(electronEnv),
860
+ executablePath,
861
+ type: 'electron'
412
862
  };
413
863
  };
414
864
 
@@ -1343,7 +1793,7 @@ const runAllTests = async ({
1343
1793
  filter,
1344
1794
  headless,
1345
1795
  onlyExtension,
1346
- serverPath,
1796
+ runtimeOptions,
1347
1797
  testPath,
1348
1798
  testWorkerUri,
1349
1799
  timeout,
@@ -1357,7 +1807,7 @@ const runAllTests = async ({
1357
1807
  path,
1358
1808
  stdio: 'inherit'
1359
1809
  });
1360
- await rpc.invoke(RunAllTests, onlyExtension, testPath, cwd, browser, headless, timeout, serverPath, traceFocus, filter);
1810
+ await rpc.invoke(RunAllTests, onlyExtension, testPath, cwd, browser, headless, timeout, runtimeOptions, traceFocus, filter);
1361
1811
  await rpc.dispose();
1362
1812
  };
1363
1813
 
@@ -1373,15 +1823,48 @@ const handleCliArgs = async ({
1373
1823
  });
1374
1824
  const {
1375
1825
  browser,
1826
+ electronArgs,
1827
+ electronCacheDir,
1828
+ electronEnv,
1829
+ electronPath,
1830
+ electronVersion,
1376
1831
  filter,
1377
1832
  headless,
1833
+ help,
1378
1834
  onlyExtension,
1835
+ runtime,
1379
1836
  serverPath,
1380
1837
  testPath,
1381
1838
  traceFocus
1382
1839
  } = options;
1840
+ if (help) {
1841
+ console.info(getHelpMessage());
1842
+ return;
1843
+ }
1383
1844
  const timeout = 30_000;
1384
1845
  const testWorkerUri = getTestWorkerUrl();
1846
+ const runtimeOptions = await getRuntimeOptions({
1847
+ cwd,
1848
+ ...(electronArgs && {
1849
+ electronArgs
1850
+ }),
1851
+ ...(electronCacheDir && {
1852
+ electronCacheDir
1853
+ }),
1854
+ ...(electronEnv && {
1855
+ electronEnv
1856
+ }),
1857
+ ...(electronPath && {
1858
+ electronPath
1859
+ }),
1860
+ ...(electronVersion && {
1861
+ electronVersion
1862
+ }),
1863
+ runtime,
1864
+ ...(serverPath && {
1865
+ serverPath
1866
+ })
1867
+ });
1385
1868
  await runAllTests({
1386
1869
  browser,
1387
1870
  commandMap,
@@ -1390,7 +1873,7 @@ const handleCliArgs = async ({
1390
1873
  headless,
1391
1874
  // @ts-ignore
1392
1875
  onlyExtension,
1393
- serverPath,
1876
+ runtimeOptions,
1394
1877
  testPath,
1395
1878
  testWorkerUri,
1396
1879
  timeout,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lvce-editor/test-with-playwright",
3
- "version": "18.0.0",
3
+ "version": "19.0.0",
4
4
  "description": "CLI tool for running Playwright tests",
5
5
  "repository": {
6
6
  "type": "git",
@@ -12,8 +12,8 @@
12
12
  "main": "dist/main.js",
13
13
  "bin": "bin/test-with-playwright.js",
14
14
  "dependencies": {
15
- "@lvce-editor/test-with-playwright-worker": "18.0.0",
16
- "@lvce-editor/test-worker": "^16.0.0"
15
+ "@lvce-editor/test-with-playwright-worker": "19.0.0",
16
+ "@lvce-editor/test-worker": "^16.2.0"
17
17
  },
18
18
  "engines": {
19
19
  "node": ">=24"