@corti/dictation-web 0.0.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 (121) hide show
  1. package/.editorconfig +29 -0
  2. package/.eslintrc.json +16 -0
  3. package/.husky/pre-commit +1 -0
  4. package/.storybook/main.js +8 -0
  5. package/README.md +120 -0
  6. package/demo/index.html +98 -0
  7. package/dist/src/CortiDictation.d.ts +19 -0
  8. package/dist/src/CortiDictation.js +137 -0
  9. package/dist/src/CortiDictation.js.map +1 -0
  10. package/dist/src/DictationService.d.ts +13 -0
  11. package/dist/src/DictationService.js +70 -0
  12. package/dist/src/DictationService.js.map +1 -0
  13. package/dist/src/RecorderManager.d.ts +20 -0
  14. package/dist/src/RecorderManager.js +85 -0
  15. package/dist/src/RecorderManager.js.map +1 -0
  16. package/dist/src/audioRecorderManager.d.ts +17 -0
  17. package/dist/src/audioRecorderManager.js +78 -0
  18. package/dist/src/audioRecorderManager.js.map +1 -0
  19. package/dist/src/audioService.d.ts +6 -0
  20. package/dist/src/audioService.js +21 -0
  21. package/dist/src/audioService.js.map +1 -0
  22. package/dist/src/componentStyles.d.ts +1 -0
  23. package/dist/src/componentStyles.js +51 -0
  24. package/dist/src/componentStyles.js.map +1 -0
  25. package/dist/src/components/audio-visualiser.d.ts +12 -0
  26. package/dist/src/components/audio-visualiser.js +60 -0
  27. package/dist/src/components/audio-visualiser.js.map +1 -0
  28. package/dist/src/components/settings-menu.d.ts +15 -0
  29. package/dist/src/components/settings-menu.js +148 -0
  30. package/dist/src/components/settings-menu.js.map +1 -0
  31. package/dist/src/components/visualiser.d.ts +7 -0
  32. package/dist/src/components/visualiser.js +62 -0
  33. package/dist/src/components/visualiser.js.map +1 -0
  34. package/dist/src/constants.d.ts +3 -0
  35. package/dist/src/constants.js +9 -0
  36. package/dist/src/constants.js.map +1 -0
  37. package/dist/src/corti-dictation.d.ts +1 -0
  38. package/dist/src/corti-dictation.js +3 -0
  39. package/dist/src/corti-dictation.js.map +1 -0
  40. package/dist/src/dictationService.d.ts +13 -0
  41. package/dist/src/dictationService.js +70 -0
  42. package/dist/src/dictationService.js.map +1 -0
  43. package/dist/src/icons/icons.d.ts +17 -0
  44. package/dist/src/icons/icons.js +153 -0
  45. package/dist/src/icons/icons.js.map +1 -0
  46. package/dist/src/icons/index.d.ts +0 -0
  47. package/dist/src/icons/index.js +2 -0
  48. package/dist/src/icons/index.js.map +1 -0
  49. package/dist/src/icons/micOn.d.ts +7 -0
  50. package/dist/src/icons/micOn.js +25 -0
  51. package/dist/src/icons/micOn.js.map +1 -0
  52. package/dist/src/index.d.ts +20 -0
  53. package/dist/src/index.js +147 -0
  54. package/dist/src/index.js.map +1 -0
  55. package/dist/src/mediaRecorderService.d.ts +6 -0
  56. package/dist/src/mediaRecorderService.js +31 -0
  57. package/dist/src/mediaRecorderService.js.map +1 -0
  58. package/dist/src/mic-selector.d.ts +18 -0
  59. package/dist/src/mic-selector.js +131 -0
  60. package/dist/src/mic-selector.js.map +1 -0
  61. package/dist/src/settings-menu.d.ts +18 -0
  62. package/dist/src/settings-menu.js +131 -0
  63. package/dist/src/settings-menu.js.map +1 -0
  64. package/dist/src/settings-popover.d.ts +18 -0
  65. package/dist/src/settings-popover.js +131 -0
  66. package/dist/src/settings-popover.js.map +1 -0
  67. package/dist/src/settings.d.ts +18 -0
  68. package/dist/src/settings.js +131 -0
  69. package/dist/src/settings.js.map +1 -0
  70. package/dist/src/styles/ComponentStyles.d.ts +2 -0
  71. package/dist/src/styles/ComponentStyles.js +52 -0
  72. package/dist/src/styles/ComponentStyles.js.map +1 -0
  73. package/dist/src/styles/buttons.d.ts +2 -0
  74. package/dist/src/styles/buttons.js +58 -0
  75. package/dist/src/styles/buttons.js.map +1 -0
  76. package/dist/src/styles/callout.d.ts +2 -0
  77. package/dist/src/styles/callout.js +26 -0
  78. package/dist/src/styles/callout.js.map +1 -0
  79. package/dist/src/styles/select.d.ts +2 -0
  80. package/dist/src/styles/select.js +36 -0
  81. package/dist/src/styles/select.js.map +1 -0
  82. package/dist/src/styles/theme.d.ts +2 -0
  83. package/dist/src/styles/theme.js +74 -0
  84. package/dist/src/styles/theme.js.map +1 -0
  85. package/dist/src/types.d.ts +20 -0
  86. package/dist/src/types.js +2 -0
  87. package/dist/src/types.js.map +1 -0
  88. package/dist/src/utils.d.ts +31 -0
  89. package/dist/src/utils.js +77 -0
  90. package/dist/src/utils.js.map +1 -0
  91. package/dist/stories/index.stories.d.ts +33 -0
  92. package/dist/stories/index.stories.js +37 -0
  93. package/dist/stories/index.stories.js.map +1 -0
  94. package/dist/test/corti-dictation.test.d.ts +1 -0
  95. package/dist/test/corti-dictation.test.js +100 -0
  96. package/dist/test/corti-dictation.test.js.map +1 -0
  97. package/dist/tsconfig.tsbuildinfo +1 -0
  98. package/docs/DEV_README.md +80 -0
  99. package/package.json +92 -0
  100. package/src/DictationService.ts +99 -0
  101. package/src/RecorderManager.ts +114 -0
  102. package/src/audioService.ts +25 -0
  103. package/src/components/audio-visualiser.ts +56 -0
  104. package/src/components/settings-menu.ts +152 -0
  105. package/src/constants.ts +10 -0
  106. package/src/corti-dictation.ts +3 -0
  107. package/src/icons/icons.ts +141 -0
  108. package/src/icons/index.ts +0 -0
  109. package/src/index.ts +154 -0
  110. package/src/styles/ComponentStyles.ts +53 -0
  111. package/src/styles/buttons.ts +59 -0
  112. package/src/styles/callout.ts +27 -0
  113. package/src/styles/select.ts +37 -0
  114. package/src/styles/theme.ts +75 -0
  115. package/src/types.ts +28 -0
  116. package/src/utils.ts +83 -0
  117. package/stories/index.stories.ts +60 -0
  118. package/test/corti-dictation.test.ts +124 -0
  119. package/tsconfig.json +22 -0
  120. package/web-dev-server.config.js +27 -0
  121. package/web-test-runner.config.js +41 -0
@@ -0,0 +1,75 @@
1
+ import { css } from 'lit';
2
+
3
+ const ThemeStyles = css`
4
+ :host {
5
+ /* Component Defaults */
6
+ --component-font-family: 'Segoe UI', Roboto, sans-serif;
7
+ --component-text-color: #333;
8
+
9
+ /* Card Defaults */
10
+ --card-background: #fff;
11
+ --card-border-color: #ddd;
12
+ --card-padding: 4px;
13
+ --card-border-radius: 8px;
14
+ --card-inner-border-radius: 6px;
15
+ --card-box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
16
+
17
+ /* Actions Defaults */
18
+ --action-plain-border-color: #ccc;
19
+ --action-plain-background-hover: #ddd;
20
+
21
+ --action-accent-background: #007bff;
22
+ --action-accent-background-hover: #0056b3;
23
+ --action-accent-text-color: #fff;
24
+
25
+ --action-red-background: #dc3545;
26
+ --action-red-background-hover: #bd2130;
27
+ --action-red-text-color: #fff;
28
+
29
+ /* Callout Defaults */
30
+ --callout-accent-background: #007bff33;
31
+ --callout-accent-border: #007bff99;
32
+ --callout-accent-text: #007bff;
33
+
34
+ --callout-red-background: #dc354533;
35
+ --callout-red-border: #dc354599;
36
+ --callout-red-text: #dc3545;
37
+
38
+ --callout-orange-background: #fd7e1433;
39
+ --callout-orange-border: #fd7e1499;
40
+ --callout-orange-text: #fd7e14;
41
+
42
+ /* Visualiser Defaults */
43
+ --visualiser-background: #e0e0e0;
44
+ --visualiser-level-color: #28a745;
45
+ }
46
+
47
+ @media (prefers-color-scheme: dark) {
48
+ :host {
49
+ /* Component Dark */
50
+ --component-text-color: #eee;
51
+
52
+ /* Card Dark */
53
+ --card-background: #333;
54
+ --card-border-color: #555;
55
+
56
+ /* Actions Dark */
57
+ --action-plain-border-color: #555;
58
+ --action-plain-background: #333;
59
+ --action-plain-background-hover: #444;
60
+
61
+ --action-accent-background: #0056b3;
62
+ --action-accent-background-hover: #003d80;
63
+
64
+ /* Visualiser Dark */
65
+ --visualiser-background: #fff;
66
+ }
67
+ }
68
+ :host {
69
+ box-sizing: border-box;
70
+ font-family: var(--component-font-family);
71
+ color: var(--component-text-color);
72
+ }
73
+ `;
74
+
75
+ export default ThemeStyles;
package/src/types.ts ADDED
@@ -0,0 +1,28 @@
1
+ export type RecordingState =
2
+ | 'initializing'
3
+ | 'recording'
4
+ | 'stopping'
5
+ | 'stopped';
6
+
7
+ export interface Command {
8
+ command: string;
9
+ action: string;
10
+ keywords: string[];
11
+ }
12
+
13
+ export interface DictationConfig {
14
+ primaryLanguage: string;
15
+ interimResults: boolean;
16
+ spokenPunctuation: boolean;
17
+ automaticPunctuation: boolean;
18
+ model: string;
19
+ commands?: Command[];
20
+ }
21
+
22
+ export type PartialDictationConfig = Partial<DictationConfig>;
23
+
24
+ export interface ServerConfig {
25
+ environment?: string;
26
+ tenant?: string;
27
+ token?: string;
28
+ }
package/src/utils.ts ADDED
@@ -0,0 +1,83 @@
1
+ /* eslint-disable no-console */
2
+ /**
3
+ * Returns the localized name of a language given its BCP-47 code.
4
+ *
5
+ * @param languageCode - The BCP-47 language code (e.g. "en")
6
+ * @returns The localized language name (e.g. "English") or the original code if unavailable.
7
+ */
8
+ export function getLanguageName(languageCode: string): string {
9
+ const userLocale = navigator.language || 'en';
10
+ const displayNames = new Intl.DisplayNames([userLocale], {
11
+ type: 'language',
12
+ });
13
+ const languageName = displayNames.of(languageCode);
14
+ return languageName || languageCode;
15
+ }
16
+
17
+ /**
18
+ * Requests access to the microphone.
19
+ *
20
+ * This function checks if the microphone permission is in "prompt" state, then requests
21
+ * access and stops any active tracks immediately. It also logs if permission is already granted.
22
+ *
23
+ * @returns A promise that resolves when the permission request is complete.
24
+ */
25
+ export async function requestMicAccess(): Promise<void> {
26
+ try {
27
+ // Fallback if Permissions API is not available
28
+ if (!navigator.permissions) {
29
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
30
+ stream.getTracks().forEach(track => track.stop());
31
+ return;
32
+ }
33
+
34
+ const permissionStatus = await navigator.permissions.query({
35
+ // eslint-disable-next-line no-undef
36
+ name: 'microphone' as PermissionName,
37
+ });
38
+
39
+ if (permissionStatus.state === 'prompt') {
40
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
41
+ stream.getTracks().forEach(track => track.stop());
42
+ } else if (permissionStatus.state === 'denied') {
43
+ console.warn('Microphone permission is denied.');
44
+ }
45
+ } catch (error) {
46
+ console.error('Error checking/requesting microphone permission:', error);
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Retrieves available audio input devices.
52
+ *
53
+ * This function uses the mediaDevices API to enumerate devices and filters out those
54
+ * which are audio inputs. In some browsers, you may need to request user media before
55
+ * device labels are populated.
56
+ *
57
+ * @returns A promise that resolves with an object containing:
58
+ * - `devices`: an array of MediaDeviceInfo objects for audio inputs.
59
+ * - `defaultDeviceId`: the deviceId of the first audio input, if available.
60
+ */
61
+ export async function getAudioDevices(): Promise<{
62
+ devices: MediaDeviceInfo[];
63
+ defaultDeviceId?: string;
64
+ }> {
65
+ if (!navigator.mediaDevices?.enumerateDevices) {
66
+ console.error('Media devices API not supported.');
67
+ return { devices: [] };
68
+ }
69
+
70
+ await requestMicAccess();
71
+
72
+ try {
73
+ // Optionally: await navigator.mediaDevices.getUserMedia({ audio: true });
74
+ const devices = await navigator.mediaDevices.enumerateDevices();
75
+ const audioDevices = devices.filter(device => device.kind === 'audioinput');
76
+ const defaultDeviceId =
77
+ audioDevices.length > 0 ? audioDevices[0].deviceId : undefined;
78
+ return { devices: audioDevices, defaultDeviceId };
79
+ } catch (error) {
80
+ console.error('Error enumerating devices:', error);
81
+ return { devices: [] };
82
+ }
83
+ }
@@ -0,0 +1,60 @@
1
+ import { html, TemplateResult } from 'lit';
2
+ import '../src/corti-dictation.js';
3
+
4
+ export default {
5
+ title: 'CortiDictation',
6
+ component: 'corti-dictation',
7
+ argTypes: {
8
+ header: { control: 'text' },
9
+ counter: { control: 'number' },
10
+ textColor: { control: 'color' },
11
+ },
12
+ };
13
+
14
+ interface Story<T> {
15
+ (args: T): TemplateResult;
16
+ args?: Partial<T>;
17
+ argTypes?: Record<string, unknown>;
18
+ }
19
+
20
+ interface ArgTypes {
21
+ header?: string;
22
+ counter?: number;
23
+ textColor?: string;
24
+ slot?: TemplateResult;
25
+ }
26
+
27
+ const Template: Story<ArgTypes> = ({
28
+ header = 'Hello world',
29
+ counter = 5,
30
+ textColor,
31
+ slot,
32
+ }: ArgTypes) => html`
33
+ <corti-dictation
34
+ style="--corti-dictation-text-color: ${textColor || 'black'}"
35
+ .header=${header}
36
+ .counter=${counter}
37
+ >
38
+ ${slot}
39
+ </corti-dictation>
40
+ `;
41
+
42
+ export const Regular = Template.bind({});
43
+
44
+ export const CustomHeader = Template.bind({});
45
+ CustomHeader.args = {
46
+ header: 'My header',
47
+ };
48
+
49
+ export const CustomCounter = Template.bind({});
50
+ CustomCounter.args = {
51
+ counter: 123456,
52
+ };
53
+
54
+ export const SlottedContent = Template.bind({});
55
+ SlottedContent.args = {
56
+ slot: html`<p>Slotted content</p>`,
57
+ };
58
+ SlottedContent.argTypes = {
59
+ slot: { table: { disable: true } },
60
+ };
@@ -0,0 +1,124 @@
1
+ import { html } from 'lit';
2
+ import { fixture, expect, oneEvent } from '@open-wc/testing';
3
+ import sinon from 'sinon';
4
+ import { CortiDictation } from '../src/index.js';
5
+ import '../src/corti-dictation.js';
6
+
7
+ // Stub class for RecorderManager
8
+ class StubRecorderManager extends EventTarget {
9
+ devices: MediaDeviceInfo[] = [];
10
+
11
+ selectedDevice = '';
12
+
13
+ startRecording = sinon.spy();
14
+
15
+ stopRecording = sinon.spy();
16
+
17
+ async initialize() {
18
+ // Simulate async initialization.
19
+ }
20
+ }
21
+
22
+ describe('CortiDictation', () => {
23
+ let stubRecorder: StubRecorderManager;
24
+
25
+ beforeEach(() => {
26
+ stubRecorder = new StubRecorderManager();
27
+ });
28
+
29
+ it('renders a callout warning if serverConfig is not configured', async () => {
30
+ const el = await fixture<CortiDictation>(
31
+ html`<corti-dictation></corti-dictation>`
32
+ );
33
+ // Override the recorderManager to avoid real initialization.
34
+ (el as any).recorderManager = stubRecorder;
35
+ el.serverConfig = {}; // Missing required keys.
36
+ await el.updateComplete;
37
+ const callout = el.shadowRoot?.querySelector('.callout');
38
+ expect(callout).to.exist;
39
+ expect(el.shadowRoot?.textContent).to.include(
40
+ 'Please configure the server settings in the parent component'
41
+ );
42
+ });
43
+
44
+ it('renders the recording icon when recordingState is "recording"', async () => {
45
+ const configured = { token: 'abc', environment: 'prod', tenant: '123' };
46
+ const el = await fixture<CortiDictation>(
47
+ html`<corti-dictation></corti-dictation>`
48
+ );
49
+ (el as any).recorderManager = stubRecorder;
50
+ el.serverConfig = configured;
51
+ el.recordingState = 'recording';
52
+ await el.updateComplete;
53
+ const recordingIcon = el.shadowRoot?.querySelector('icon-recording');
54
+ expect(recordingIcon).to.exist;
55
+ });
56
+
57
+ it('calls startRecording when button is clicked and state is "stopped"', async () => {
58
+ const configured = { token: 'abc', environment: 'prod', tenant: '123' };
59
+ const el = await fixture<CortiDictation>(
60
+ html`<corti-dictation></corti-dictation>`
61
+ );
62
+ (el as any).recorderManager = stubRecorder;
63
+ el.serverConfig = configured;
64
+ el.recordingState = 'stopped';
65
+ await el.updateComplete;
66
+ const button = el.shadowRoot!.querySelector('button')!;
67
+ button.click();
68
+ expect(stubRecorder.startRecording.calledOnce).to.be.true;
69
+ // Check that startRecording was called with a config object
70
+ const args = stubRecorder.startRecording.getCall(0).args[0];
71
+ expect(args).to.have.property('dictationConfig');
72
+ expect(args).to.have.property('serverConfig');
73
+ });
74
+
75
+ it('calls stopRecording when button is clicked and state is "recording"', async () => {
76
+ const configured = { token: 'abc', environment: 'prod', tenant: '123' };
77
+ const el = await fixture<CortiDictation>(
78
+ html`<corti-dictation></corti-dictation>`
79
+ );
80
+ (el as any).recorderManager = stubRecorder;
81
+ el.serverConfig = configured;
82
+ el.recordingState = 'recording';
83
+ await el.updateComplete;
84
+ const button = el.shadowRoot!.querySelector('button')!;
85
+ button.click();
86
+ expect(stubRecorder.stopRecording.calledOnce).to.be.true;
87
+ });
88
+
89
+ it('updates the audio level when an "audio-level-changed" event is dispatched', async () => {
90
+ const configured = { token: 'abc', environment: 'prod', tenant: '123' };
91
+ const el = await fixture<CortiDictation>(
92
+ html`<corti-dictation></corti-dictation>`
93
+ );
94
+ (el as any).recorderManager = stubRecorder;
95
+ el.serverConfig = configured;
96
+ el.recordingState = 'recording';
97
+ await el.updateComplete;
98
+ const testLevel = 42;
99
+ const event = new CustomEvent('audio-level-changed', { detail: { audioLevel: testLevel } });
100
+ stubRecorder.dispatchEvent(event);
101
+ await el.updateComplete;
102
+ const audioVisualiser = el.shadowRoot?.querySelector('audio-visualiser');
103
+ expect(audioVisualiser).to.exist;
104
+ expect((audioVisualiser as any).level).to.equal(testLevel);
105
+ });
106
+
107
+ it('re-dispatches recorderManager events', async () => {
108
+ const configured = { token: 'abc', environment: 'prod', tenant: '123' };
109
+ const el = await fixture<CortiDictation>(
110
+ html`<corti-dictation></corti-dictation>`
111
+ );
112
+ (el as any).recorderManager = stubRecorder;
113
+ el.serverConfig = configured;
114
+ el.recordingState = 'stopped';
115
+ await el.updateComplete;
116
+ // Listen for a re-dispatched event from the component.
117
+ setTimeout(() => {
118
+ const event = new CustomEvent('transcript', { detail: { transcript: 'hello' } });
119
+ stubRecorder.dispatchEvent(event);
120
+ });
121
+ const dispatchedEvent = (await oneEvent(el, 'transcript')) as CustomEvent;
122
+ expect(dispatchedEvent.detail.transcript).to.equal('hello');
123
+ });
124
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,22 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "es2021",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "noEmitOnError": true,
7
+ "lib": ["es2021", "dom", "DOM.Iterable"],
8
+ "strict": true,
9
+ "esModuleInterop": false,
10
+ "allowSyntheticDefaultImports": true,
11
+ "experimentalDecorators": true,
12
+ "importHelpers": true,
13
+ "outDir": "dist",
14
+ "sourceMap": true,
15
+ "inlineSources": true,
16
+ "rootDir": "./",
17
+ "declaration": true,
18
+ "incremental": true,
19
+ "skipLibCheck": true
20
+ },
21
+ "include": ["**/*.ts"]
22
+ }
@@ -0,0 +1,27 @@
1
+ // import { hmrPlugin, presets } from '@open-wc/dev-server-hmr';
2
+
3
+ /** Use Hot Module replacement by adding --hmr to the start command */
4
+ const hmr = process.argv.includes('--hmr');
5
+
6
+ export default /** @type {import('@web/dev-server').DevServerConfig} */ ({
7
+ open: '/demo/',
8
+ /** Use regular watch mode if HMR is not enabled. */
9
+ watch: !hmr,
10
+ /** Resolve bare module imports */
11
+ nodeResolve: {
12
+ exportConditions: ['browser', 'development'],
13
+ },
14
+
15
+ /** Compile JS for older browsers. Requires @web/dev-server-esbuild plugin */
16
+ // esbuildTarget: 'auto'
17
+
18
+ /** Set appIndex to enable SPA routing */
19
+ // appIndex: 'demo/index.html',
20
+
21
+ plugins: [
22
+ /** Use Hot Module Replacement by uncommenting. Requires @open-wc/dev-server-hmr plugin */
23
+ // hmr && hmrPlugin({ exclude: ['**/*/node_modules/**/*'], presets: [presets.lit] }),
24
+ ],
25
+
26
+ // See documentation for all available options
27
+ });
@@ -0,0 +1,41 @@
1
+ // import { playwrightLauncher } from '@web/test-runner-playwright';
2
+
3
+ const filteredLogs = ['Running in dev mode', 'Lit is in dev mode'];
4
+
5
+ export default /** @type {import("@web/test-runner").TestRunnerConfig} */ ({
6
+ /** Test files to run */
7
+ files: 'dist/test/**/*.test.js',
8
+
9
+ /** Resolve bare module imports */
10
+ nodeResolve: {
11
+ exportConditions: ['browser', 'development'],
12
+ },
13
+
14
+ /** Filter out lit dev mode logs */
15
+ filterBrowserLogs(log) {
16
+ for (const arg of log.args) {
17
+ if (typeof arg === 'string' && filteredLogs.some(l => arg.includes(l))) {
18
+ return false;
19
+ }
20
+ }
21
+ return true;
22
+ },
23
+
24
+ /** Compile JS for older browsers. Requires @web/dev-server-esbuild plugin */
25
+ // esbuildTarget: 'auto',
26
+
27
+ /** Amount of browsers to run concurrently */
28
+ // concurrentBrowsers: 2,
29
+
30
+ /** Amount of test files per browser to test concurrently */
31
+ // concurrency: 1,
32
+
33
+ /** Browsers to run tests on */
34
+ // browsers: [
35
+ // playwrightLauncher({ product: 'chromium' }),
36
+ // playwrightLauncher({ product: 'firefox' }),
37
+ // playwrightLauncher({ product: 'webkit' }),
38
+ // ],
39
+
40
+ // See documentation for all available options
41
+ });