Spaces:
Running
on
CPU Upgrade
Running
on
CPU Upgrade
Commit
•
24c6b45
1
Parent(s):
0f737ff
setup unit and e2e tests
Browse files- .github/workflows/playwright.yml +27 -0
- .gitignore +5 -1
- README.md +35 -1
- package-lock.json +0 -0
- package.json +13 -4
- playwright.config.ts +78 -0
- src/components/toolbars/top-menu/assistant/index.tsx +2 -2
- src/lib/hf/adapter/findMainGradioEndpoint.ts +3 -3
- src/lib/utils/formatDuration.test.ts +10 -0
- src/lib/utils/formatDuration.ts +22 -20
- src/services/assistant/getDefaultAssistantState.ts +0 -3
- src/services/assistant/useAssistant.ts +1 -51
- src/services/assistant/useVoiceAssistant.ts +13 -0
- src/services/debug.ts +2 -0
- src/services/mic/getDefaultMicState.ts +16 -0
- src/services/mic/useMic.ts +76 -0
- src/services/plugins/usePlugins.ts +2 -0
- tests/examples.spec.txt +437 -0
- tests/main.spec.ts +19 -0
- tsconfig.json +1 -1
- vitest.config.mts +14 -0
.github/workflows/playwright.yml
ADDED
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
name: Playwright Tests
|
2 |
+
on:
|
3 |
+
push:
|
4 |
+
branches: [ main, master ]
|
5 |
+
pull_request:
|
6 |
+
branches: [ main, master ]
|
7 |
+
jobs:
|
8 |
+
test:
|
9 |
+
timeout-minutes: 60
|
10 |
+
runs-on: ubuntu-latest
|
11 |
+
steps:
|
12 |
+
- uses: actions/checkout@v4
|
13 |
+
- uses: actions/setup-node@v4
|
14 |
+
with:
|
15 |
+
node-version: lts/*
|
16 |
+
- name: Install dependencies
|
17 |
+
run: npm ci
|
18 |
+
- name: Install Playwright Browsers
|
19 |
+
run: npx playwright install --with-deps
|
20 |
+
- name: Run Playwright tests
|
21 |
+
run: npx playwright test
|
22 |
+
- uses: actions/upload-artifact@v4
|
23 |
+
if: always()
|
24 |
+
with:
|
25 |
+
name: playwright-report
|
26 |
+
path: playwright-report/
|
27 |
+
retention-days: 30
|
.gitignore
CHANGED
@@ -35,4 +35,8 @@ yarn-error.log*
|
|
35 |
*.tsbuildinfo
|
36 |
next-env.d.ts
|
37 |
|
38 |
-
/sandbox/
|
|
|
|
|
|
|
|
|
|
35 |
*.tsbuildinfo
|
36 |
next-env.d.ts
|
37 |
|
38 |
+
/sandbox/
|
39 |
+
/test-results/
|
40 |
+
/playwright-report/
|
41 |
+
/blob-report/
|
42 |
+
/playwright/.cache/
|
README.md
CHANGED
@@ -100,7 +100,41 @@ I haven't setup Prettier or a Linter yet.
|
|
100 |
|
101 |
### Testing
|
102 |
|
103 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
104 |
|
105 |
### Architecture
|
106 |
|
|
|
100 |
|
101 |
### Testing
|
102 |
|
103 |
+
@jbilcke-hf is working to add tests, and will also act as the "QA engineer".
|
104 |
+
|
105 |
+
#### Unit tests
|
106 |
+
|
107 |
+
Note: I've just added Vitest, we don't have tests yet.
|
108 |
+
|
109 |
+
`npm run test`
|
110 |
+
|
111 |
+
#### End-to-end tests
|
112 |
+
|
113 |
+
|
114 |
+
Note: I've just added Playwright, we don't have tests yet.
|
115 |
+
|
116 |
+
`npx playwright test`
|
117 |
+
Runs the end-to-end tests.
|
118 |
+
|
119 |
+
`npx playwright test --ui`
|
120 |
+
Starts the interactive UI mode.
|
121 |
+
|
122 |
+
`npx playwright test --project=chromium`
|
123 |
+
Runs the tests only on Desktop Chrome.
|
124 |
+
|
125 |
+
`npx playwright test example`
|
126 |
+
Runs the tests in a specific file.
|
127 |
+
|
128 |
+
` npx playwright test --debug`
|
129 |
+
Runs the tests in debug mode.
|
130 |
+
|
131 |
+
`npx playwright codegen`
|
132 |
+
Auto generate tests with Codegen.
|
133 |
+
|
134 |
+
We suggest that you begin by typing:
|
135 |
+
|
136 |
+
`npx playwright test`
|
137 |
+
|
138 |
|
139 |
### Architecture
|
140 |
|
package-lock.json
CHANGED
The diff for this file is too large to render.
See raw diff
|
|
package.json
CHANGED
@@ -1,6 +1,6 @@
|
|
1 |
{
|
2 |
"name": "@aitube/clapper",
|
3 |
-
"version": "0.0.
|
4 |
"private": true,
|
5 |
"description": "🎬 Clapper",
|
6 |
"scripts": {
|
@@ -8,12 +8,14 @@
|
|
8 |
"build": "npm i && next build",
|
9 |
"start": "next start",
|
10 |
"lint": "next lint",
|
11 |
-
"lint:fix": "next lint --fix"
|
|
|
|
|
12 |
},
|
13 |
"dependencies": {
|
14 |
"@aitube/broadway": "0.0.22",
|
15 |
"@aitube/clap": "0.0.30",
|
16 |
-
"@aitube/clapper-services": "0.0.
|
17 |
"@aitube/engine": "0.0.26",
|
18 |
"@aitube/timeline": "0.0.42",
|
19 |
"@fal-ai/serverless-client": "^0.11.0",
|
@@ -55,12 +57,14 @@
|
|
55 |
"@react-three/uikit": "^0.3.4",
|
56 |
"@react-three/uikit-lucide": "^0.3.4",
|
57 |
"@tailwindcss/container-queries": "^0.1.1",
|
|
|
58 |
"@upstash/ratelimit": "^1.1.3",
|
59 |
"@upstash/redis": "^1.31.1",
|
60 |
"autoprefixer": "10.4.19",
|
61 |
"class-variance-authority": "^0.7.0",
|
62 |
"clsx": "^2.1.1",
|
63 |
"cmdk": "^0.2.1",
|
|
|
64 |
"fflate": "^0.8.2",
|
65 |
"fluent-ffmpeg": "^2.1.3",
|
66 |
"framer-motion": "11.1.7",
|
@@ -100,17 +104,22 @@
|
|
100 |
"zx": "^8.1.3"
|
101 |
},
|
102 |
"devDependencies": {
|
|
|
|
|
103 |
"@types/fluent-ffmpeg": "^2.1.24",
|
104 |
"@types/is-hotkey": "^0.1.10",
|
105 |
"@types/node": "^20",
|
106 |
"@types/react": "^18",
|
107 |
"@types/react-dom": "^18",
|
108 |
"@types/uuid": "^9.0.8",
|
|
|
109 |
"eslint": "^8",
|
110 |
"eslint-config-next": "14.2.4",
|
|
|
111 |
"postcss": "^8",
|
112 |
"tailwind-scrollbar": "^3.1.0",
|
113 |
"tailwindcss": "^3.4.3",
|
114 |
-
"typescript": "^5"
|
|
|
115 |
}
|
116 |
}
|
|
|
1 |
{
|
2 |
"name": "@aitube/clapper",
|
3 |
+
"version": "0.0.5",
|
4 |
"private": true,
|
5 |
"description": "🎬 Clapper",
|
6 |
"scripts": {
|
|
|
8 |
"build": "npm i && next build",
|
9 |
"start": "next start",
|
10 |
"lint": "next lint",
|
11 |
+
"lint:fix": "next lint --fix",
|
12 |
+
"test:unit": "vitest",
|
13 |
+
"test:e2e": "npx playwright test"
|
14 |
},
|
15 |
"dependencies": {
|
16 |
"@aitube/broadway": "0.0.22",
|
17 |
"@aitube/clap": "0.0.30",
|
18 |
+
"@aitube/clapper-services": "0.0.25",
|
19 |
"@aitube/engine": "0.0.26",
|
20 |
"@aitube/timeline": "0.0.42",
|
21 |
"@fal-ai/serverless-client": "^0.11.0",
|
|
|
57 |
"@react-three/uikit": "^0.3.4",
|
58 |
"@react-three/uikit-lucide": "^0.3.4",
|
59 |
"@tailwindcss/container-queries": "^0.1.1",
|
60 |
+
"@types/dom-speech-recognition": "^0.0.4",
|
61 |
"@upstash/ratelimit": "^1.1.3",
|
62 |
"@upstash/redis": "^1.31.1",
|
63 |
"autoprefixer": "10.4.19",
|
64 |
"class-variance-authority": "^0.7.0",
|
65 |
"clsx": "^2.1.1",
|
66 |
"cmdk": "^0.2.1",
|
67 |
+
"date-fns": "^3.6.0",
|
68 |
"fflate": "^0.8.2",
|
69 |
"fluent-ffmpeg": "^2.1.3",
|
70 |
"framer-motion": "11.1.7",
|
|
|
104 |
"zx": "^8.1.3"
|
105 |
},
|
106 |
"devDependencies": {
|
107 |
+
"@playwright/test": "^1.45.1",
|
108 |
+
"@testing-library/react": "^16.0.0",
|
109 |
"@types/fluent-ffmpeg": "^2.1.24",
|
110 |
"@types/is-hotkey": "^0.1.10",
|
111 |
"@types/node": "^20",
|
112 |
"@types/react": "^18",
|
113 |
"@types/react-dom": "^18",
|
114 |
"@types/uuid": "^9.0.8",
|
115 |
+
"@vitejs/plugin-react": "^4.3.1",
|
116 |
"eslint": "^8",
|
117 |
"eslint-config-next": "14.2.4",
|
118 |
+
"jsdom": "^24.1.0",
|
119 |
"postcss": "^8",
|
120 |
"tailwind-scrollbar": "^3.1.0",
|
121 |
"tailwindcss": "^3.4.3",
|
122 |
+
"typescript": "^5",
|
123 |
+
"vitest": "^2.0.2"
|
124 |
}
|
125 |
}
|
playwright.config.ts
ADDED
@@ -0,0 +1,78 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { defineConfig, devices } from '@playwright/test';
|
2 |
+
|
3 |
+
/**
|
4 |
+
* Read environment variables from file.
|
5 |
+
* https://github.com/motdotla/dotenv
|
6 |
+
*/
|
7 |
+
// import dotenv from 'dotenv';
|
8 |
+
// dotenv.config({ path: path.resolve(__dirname, '.env') });
|
9 |
+
|
10 |
+
/**
|
11 |
+
* See https://playwright.dev/docs/test-configuration.
|
12 |
+
*/
|
13 |
+
export default defineConfig({
|
14 |
+
testDir: './tests',
|
15 |
+
/* Run tests in files in parallel */
|
16 |
+
fullyParallel: true,
|
17 |
+
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
18 |
+
forbidOnly: !!process.env.CI,
|
19 |
+
/* Retry on CI only */
|
20 |
+
retries: process.env.CI ? 2 : 0,
|
21 |
+
/* Opt out of parallel tests on CI. */
|
22 |
+
workers: process.env.CI ? 1 : undefined,
|
23 |
+
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
24 |
+
reporter: 'html',
|
25 |
+
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
26 |
+
use: {
|
27 |
+
/* Base URL to use in actions like `await page.goto('/')`. */
|
28 |
+
// baseURL: 'http://127.0.0.1:3000',
|
29 |
+
|
30 |
+
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
31 |
+
trace: 'on-first-retry',
|
32 |
+
},
|
33 |
+
|
34 |
+
/* Configure projects for major browsers */
|
35 |
+
projects: [
|
36 |
+
{
|
37 |
+
name: 'chromium',
|
38 |
+
use: { ...devices['Desktop Chrome'] },
|
39 |
+
},
|
40 |
+
|
41 |
+
{
|
42 |
+
name: 'firefox',
|
43 |
+
use: { ...devices['Desktop Firefox'] },
|
44 |
+
},
|
45 |
+
|
46 |
+
{
|
47 |
+
name: 'webkit',
|
48 |
+
use: { ...devices['Desktop Safari'] },
|
49 |
+
},
|
50 |
+
|
51 |
+
/* Test against mobile viewports. */
|
52 |
+
// {
|
53 |
+
// name: 'Mobile Chrome',
|
54 |
+
// use: { ...devices['Pixel 5'] },
|
55 |
+
// },
|
56 |
+
// {
|
57 |
+
// name: 'Mobile Safari',
|
58 |
+
// use: { ...devices['iPhone 12'] },
|
59 |
+
// },
|
60 |
+
|
61 |
+
/* Test against branded browsers. */
|
62 |
+
// {
|
63 |
+
// name: 'Microsoft Edge',
|
64 |
+
// use: { ...devices['Desktop Edge'], channel: 'msedge' },
|
65 |
+
// },
|
66 |
+
// {
|
67 |
+
// name: 'Google Chrome',
|
68 |
+
// use: { ...devices['Desktop Chrome'], channel: 'chrome' },
|
69 |
+
// },
|
70 |
+
],
|
71 |
+
|
72 |
+
/* Run your local dev server before starting the tests */
|
73 |
+
// webServer: {
|
74 |
+
// command: 'npm run start',
|
75 |
+
// url: 'http://127.0.0.1:3000',
|
76 |
+
// reuseExistingServer: !process.env.CI,
|
77 |
+
// },
|
78 |
+
});
|
src/components/toolbars/top-menu/assistant/index.tsx
CHANGED
@@ -14,13 +14,13 @@ import {
|
|
14 |
import { useUI } from "@/services/ui"
|
15 |
import { SettingsCategory } from "@aitube/clapper-services"
|
16 |
import { AssistantModelList } from "../lists/AssistantModelList"
|
17 |
-
import {
|
18 |
|
19 |
export function TopMenuAssistant() {
|
20 |
const setShowSettings = useUI(s => s.setShowSettings)
|
21 |
|
22 |
// this should only be called on and at only one place in the project!
|
23 |
-
|
24 |
|
25 |
return (
|
26 |
<MenubarMenu>
|
|
|
14 |
import { useUI } from "@/services/ui"
|
15 |
import { SettingsCategory } from "@aitube/clapper-services"
|
16 |
import { AssistantModelList } from "../lists/AssistantModelList"
|
17 |
+
import { useVoiceAssistant } from "@/services/assistant/useVoiceAssistant"
|
18 |
|
19 |
export function TopMenuAssistant() {
|
20 |
const setShowSettings = useUI(s => s.setShowSettings)
|
21 |
|
22 |
// this should only be called on and at only one place in the project!
|
23 |
+
useVoiceAssistant()
|
24 |
|
25 |
return (
|
26 |
<MenubarMenu>
|
src/lib/hf/adapter/findMainGradioEndpoint.ts
CHANGED
@@ -23,9 +23,9 @@ export function findMainGradioEndpoint({
|
|
23 |
const sortableEndpoints = endpoints.map(({ isNamed, name, endpoint, score }) => {
|
24 |
console.log(`found endpoint: ${name}`)
|
25 |
|
26 |
-
const isContinuous = !!endpoint.type?.continuous
|
27 |
-
const isGenerator = !!endpoint.type?.generator
|
28 |
-
const canCancel = !!endpoint.type?.cancel
|
29 |
|
30 |
let gradioFields: Record<string, Partial<SupportedFields>> = {}
|
31 |
let allGradioFields = getDefaultFields()
|
|
|
23 |
const sortableEndpoints = endpoints.map(({ isNamed, name, endpoint, score }) => {
|
24 |
console.log(`found endpoint: ${name}`)
|
25 |
|
26 |
+
// const isContinuous = !!endpoint.type?.continuous
|
27 |
+
// const isGenerator = !!endpoint.type?.generator
|
28 |
+
// const canCancel = !!endpoint.type?.cancel
|
29 |
|
30 |
let gradioFields: Record<string, Partial<SupportedFields>> = {}
|
31 |
let allGradioFields = getDefaultFields()
|
src/lib/utils/formatDuration.test.ts
ADDED
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { expect, test } from 'vitest'
|
2 |
+
|
3 |
+
import { formatDuration } from './formatDuration'
|
4 |
+
|
5 |
+
test('formatDuration', () => {
|
6 |
+
expect(formatDuration(0)).toBe('00:00:00:000')
|
7 |
+
expect(formatDuration(1050)).toBe('00:00:01:050')
|
8 |
+
expect(formatDuration(60500)).toBe('00:01:00:500')
|
9 |
+
expect(formatDuration(3600999)).toBe('01:00:00:999')
|
10 |
+
})
|
src/lib/utils/formatDuration.ts
CHANGED
@@ -1,25 +1,27 @@
|
|
1 |
-
|
2 |
-
|
3 |
|
4 |
export function formatDuration(float_ms: number) {
|
5 |
|
6 |
-
|
7 |
-
|
8 |
-
const hours = Math.floor(float_s / HOUR);
|
9 |
-
float_s = float_s % HOUR;
|
10 |
-
const minutes = Math.floor(float_s / MINUTE);
|
11 |
-
float_s = float_s % MINUTE;
|
12 |
-
const seconds = float_s;
|
13 |
|
14 |
-
|
15 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
16 |
|
17 |
-
|
18 |
-
|
19 |
-
|
20 |
-
|
21 |
-
|
22 |
-
|
23 |
-
|
24 |
-
|
25 |
-
|
|
|
|
|
|
1 |
+
|
2 |
+
import { intervalToDuration } from 'date-fns'
|
3 |
|
4 |
export function formatDuration(float_ms: number) {
|
5 |
|
6 |
+
const duration = intervalToDuration({ start: 0, end: float_ms })
|
|
|
|
|
|
|
|
|
|
|
|
|
7 |
|
8 |
+
const hours = duration.hours || 0
|
9 |
+
const minutes = duration.minutes || 0
|
10 |
+
const seconds = duration.seconds || 0
|
11 |
+
|
12 |
+
const total =
|
13 |
+
(hours * 60 * 60 * 1000)
|
14 |
+
+ (minutes * 60 * 1000)
|
15 |
+
+ (seconds * 1000)
|
16 |
|
17 |
+
const formatted = [
|
18 |
+
duration.hours || 0,
|
19 |
+
duration.minutes || 0,
|
20 |
+
duration.seconds || 0,
|
21 |
+
float_ms - total
|
22 |
+
]
|
23 |
+
.map((num, i) => String(num as number).padStart(i === 3 ? 3 : 2, '0'))
|
24 |
+
.join(':')
|
25 |
+
|
26 |
+
return formatted
|
27 |
+
}
|
src/services/assistant/getDefaultAssistantState.ts
CHANGED
@@ -2,9 +2,6 @@ import { AssistantState } from "@aitube/clapper-services"
|
|
2 |
|
3 |
export function getDefaultAssistantState(): AssistantState {
|
4 |
const state: AssistantState = {
|
5 |
-
isVoiceEnabled: false,
|
6 |
-
transcript: "",
|
7 |
-
|
8 |
history: [],
|
9 |
}
|
10 |
|
|
|
2 |
|
3 |
export function getDefaultAssistantState(): AssistantState {
|
4 |
const state: AssistantState = {
|
|
|
|
|
|
|
5 |
history: [],
|
6 |
}
|
7 |
|
src/services/assistant/useAssistant.ts
CHANGED
@@ -1,10 +1,8 @@
|
|
1 |
"use client"
|
2 |
|
3 |
-
import { useEffect } from "react"
|
4 |
-
import { useVoiceToText } from "react-speakup"
|
5 |
import { create } from "zustand"
|
6 |
import { AssistantRequest, AssistantStore, ChatEvent } from "@aitube/clapper-services"
|
7 |
-
import { ClapOutputType,
|
8 |
import { DEFAULT_DURATION_IN_MS_PER_STEP, findFreeTrack, TimelineSegment, TimelineStore, useTimeline } from "@aitube/timeline"
|
9 |
|
10 |
import { getDefaultAssistantState } from "./getDefaultAssistantState"
|
@@ -13,36 +11,11 @@ import { useSettings } from "../settings"
|
|
13 |
import { askAssistant } from "./askAssistant"
|
14 |
import { useRenderer } from "../renderer"
|
15 |
|
16 |
-
// URL to the speech to text websocket server
|
17 |
-
export const STT_API_URL = process.env.NEXT_PUBLIC_SPEECH_TO_TEXT_API_URL || ""
|
18 |
-
|
19 |
const enableTextToSpeech = false
|
20 |
|
21 |
export const useAssistant = create<AssistantStore>((set, get) => ({
|
22 |
...getDefaultAssistantState(),
|
23 |
|
24 |
-
toggleVoice: (): boolean => {
|
25 |
-
|
26 |
-
if (!navigator?.mediaDevices?.getUserMedia || !MediaRecorder.isTypeSupported("audio/webm")) {
|
27 |
-
console.error("This environment doesn't support microphone recording")
|
28 |
-
return false
|
29 |
-
}
|
30 |
-
|
31 |
-
if (!enableTextToSpeech) {
|
32 |
-
console.error("Text to speech is currently disabled, aborting")
|
33 |
-
return false
|
34 |
-
}
|
35 |
-
|
36 |
-
const isVoiceEnabled = !get().isVoiceEnabled
|
37 |
-
|
38 |
-
set({ isVoiceEnabled })
|
39 |
-
|
40 |
-
return isVoiceEnabled
|
41 |
-
},
|
42 |
-
|
43 |
-
setVoiceTranscript: (transcript: string) => {
|
44 |
-
set({ transcript })
|
45 |
-
},
|
46 |
runCommand: (prompt: string) => {
|
47 |
|
48 |
const query = prompt
|
@@ -256,26 +229,3 @@ export const useAssistant = create<AssistantStore>((set, get) => ({
|
|
256 |
}
|
257 |
}
|
258 |
}))
|
259 |
-
|
260 |
-
export function useInitAssistant() {
|
261 |
-
const isVoiceEnabled = useAssistant(s => s.isVoiceEnabled)
|
262 |
-
const toggleVoice = useAssistant(s => s.toggleVoice)
|
263 |
-
const setVoiceTranscript = useAssistant(s => s.setVoiceTranscript)
|
264 |
-
const { startListening, stopListening, transcript } = useVoiceToText({
|
265 |
-
continuous: true
|
266 |
-
})
|
267 |
-
|
268 |
-
useEffect(() => {
|
269 |
-
if (isVoiceEnabled) {
|
270 |
-
console.log(`TODO: startListening`)
|
271 |
-
|
272 |
-
startListening()
|
273 |
-
} else {
|
274 |
-
stopListening()
|
275 |
-
}
|
276 |
-
}, [isVoiceEnabled])
|
277 |
-
|
278 |
-
useEffect(() => {
|
279 |
-
setVoiceTranscript(transcript)
|
280 |
-
}, [transcript])
|
281 |
-
}
|
|
|
1 |
"use client"
|
2 |
|
|
|
|
|
3 |
import { create } from "zustand"
|
4 |
import { AssistantRequest, AssistantStore, ChatEvent } from "@aitube/clapper-services"
|
5 |
+
import { ClapOutputType, ClapSegmentCategory, newSegment, UUID } from "@aitube/clap"
|
6 |
import { DEFAULT_DURATION_IN_MS_PER_STEP, findFreeTrack, TimelineSegment, TimelineStore, useTimeline } from "@aitube/timeline"
|
7 |
|
8 |
import { getDefaultAssistantState } from "./getDefaultAssistantState"
|
|
|
11 |
import { askAssistant } from "./askAssistant"
|
12 |
import { useRenderer } from "../renderer"
|
13 |
|
|
|
|
|
|
|
14 |
const enableTextToSpeech = false
|
15 |
|
16 |
export const useAssistant = create<AssistantStore>((set, get) => ({
|
17 |
...getDefaultAssistantState(),
|
18 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
19 |
runCommand: (prompt: string) => {
|
20 |
|
21 |
const query = prompt
|
|
|
229 |
}
|
230 |
}
|
231 |
}))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/services/assistant/useVoiceAssistant.ts
ADDED
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { useEffect } from "react"
|
2 |
+
|
3 |
+
import { useMic } from "../mic/useMic"
|
4 |
+
import { useAssistant } from "./useAssistant"
|
5 |
+
|
6 |
+
export function useVoiceAssistant() {
|
7 |
+
const processMessage = useAssistant(s => s.processMessage)
|
8 |
+
const transcript = useMic(s => s.transcript)
|
9 |
+
|
10 |
+
useEffect(() => {
|
11 |
+
processMessage(transcript)
|
12 |
+
}, [transcript])
|
13 |
+
}
|
src/services/debug.ts
CHANGED
@@ -1,5 +1,6 @@
|
|
1 |
import { useTasks } from "@/components/tasks/useTasks"
|
2 |
import { useAssistant } from "./assistant/useAssistant"
|
|
|
3 |
import { useAudio } from "./audio/useAudio"
|
4 |
import { useBroadcast } from "./broadcast/useBroadcast"
|
5 |
import { useEditors, useEntityEditor, useProjectEditor, useScriptEditor, useSegmentEditor } from "./editors"
|
@@ -18,6 +19,7 @@ if (typeof window !== "undefined") {
|
|
18 |
const w = window as any
|
19 |
w.useTasks = useTasks
|
20 |
w.useAssistant = useAssistant
|
|
|
21 |
w.useAudio = useAudio
|
22 |
w.useBroadcast = useBroadcast
|
23 |
w.useEditors = useEditors
|
|
|
1 |
import { useTasks } from "@/components/tasks/useTasks"
|
2 |
import { useAssistant } from "./assistant/useAssistant"
|
3 |
+
import { useMic } from "./mic/useMic"
|
4 |
import { useAudio } from "./audio/useAudio"
|
5 |
import { useBroadcast } from "./broadcast/useBroadcast"
|
6 |
import { useEditors, useEntityEditor, useProjectEditor, useScriptEditor, useSegmentEditor } from "./editors"
|
|
|
19 |
const w = window as any
|
20 |
w.useTasks = useTasks
|
21 |
w.useAssistant = useAssistant
|
22 |
+
w.useMic = useMic
|
23 |
w.useAudio = useAudio
|
24 |
w.useBroadcast = useBroadcast
|
25 |
w.useEditors = useEditors
|
src/services/mic/getDefaultMicState.ts
ADDED
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { MicState } from "@aitube/clapper-services"
|
2 |
+
|
3 |
+
export function getDefaultMicState(): MicState {
|
4 |
+
return {
|
5 |
+
isSupported: typeof window === 'undefined' || !('webkitSpeechRecognition' in window),
|
6 |
+
isListening: false,
|
7 |
+
transcript: '',
|
8 |
+
interimResults: true,
|
9 |
+
error: '',
|
10 |
+
lang: 'en-US',
|
11 |
+
grammar: '#JSGF V1.0; grammer punctuation; public <punc> =. |, |? | | ; | : ;',
|
12 |
+
grammarWeight: 1,
|
13 |
+
continuous: false,
|
14 |
+
recognition: undefined,
|
15 |
+
}
|
16 |
+
}
|
src/services/mic/useMic.ts
ADDED
@@ -0,0 +1,76 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"use client"
|
2 |
+
|
3 |
+
import { create } from "zustand"
|
4 |
+
import { MicStore } from "@aitube/clapper-services"
|
5 |
+
|
6 |
+
import { getDefaultMicState } from "./getDefaultMicState"
|
7 |
+
|
8 |
+
export const useMic = create<MicStore>((set, get) => ({
|
9 |
+
...getDefaultMicState(),
|
10 |
+
|
11 |
+
init: () => {
|
12 |
+
const { isSupported, interimResults, lang, continuous, grammar, grammarWeight } = get()
|
13 |
+
|
14 |
+
if (!isSupported) {
|
15 |
+
return
|
16 |
+
}
|
17 |
+
|
18 |
+
// Initialize webkitSpeechRecognition
|
19 |
+
const recognition: SpeechRecognition = new (window.SpeechRecognition || window.webkitSpeechRecognition)()
|
20 |
+
|
21 |
+
if (!recognition) {
|
22 |
+
set({ isSupported: false, error: 'this browser doesn\'t support speech recognition' })
|
23 |
+
return
|
24 |
+
}
|
25 |
+
|
26 |
+
recognition.interimResults = interimResults
|
27 |
+
recognition.lang = lang
|
28 |
+
recognition.continuous = continuous
|
29 |
+
|
30 |
+
const speechRecognitionList = new window.webkitSpeechGrammarList()
|
31 |
+
speechRecognitionList.addFromString(grammar, grammarWeight)
|
32 |
+
recognition.grammars = speechRecognitionList
|
33 |
+
|
34 |
+
const handleResult = (event: SpeechRecognitionEvent) => {
|
35 |
+
let transcript = '';
|
36 |
+
for (let i = 0; i < event.results.length; i++) {
|
37 |
+
transcript += event.results?.[i]?.[0]?.transcript || '';
|
38 |
+
}
|
39 |
+
set({ transcript });
|
40 |
+
};
|
41 |
+
|
42 |
+
const handleError = (event: SpeechRecognitionErrorEvent) => {
|
43 |
+
let error = `${event.error} ${event.message})`
|
44 |
+
|
45 |
+
if (event.error === 'aborted') {
|
46 |
+
error = 'speech recognition aborted'
|
47 |
+
}
|
48 |
+
set({ isListening: false, error })
|
49 |
+
};
|
50 |
+
|
51 |
+
const handleEnd = () => {
|
52 |
+
set({ isListening: false, transcript: '' })
|
53 |
+
};
|
54 |
+
|
55 |
+
recognition.addEventListener('result', handleResult);
|
56 |
+
recognition.addEventListener('error', handleError);
|
57 |
+
recognition.addEventListener('end', handleEnd);
|
58 |
+
|
59 |
+
set({ recognition })
|
60 |
+
},
|
61 |
+
start: () => {
|
62 |
+
const { isSupported, recognition, isListening } = get()
|
63 |
+
if (!isSupported || !recognition || isListening) { return }
|
64 |
+
recognition.start()
|
65 |
+
set({ isListening: true, error: '' })
|
66 |
+
},
|
67 |
+
stop: () => {
|
68 |
+
const { isSupported, recognition, isListening } = get()
|
69 |
+
if (!isSupported || !recognition || !isListening) { return }
|
70 |
+
recognition.stop()
|
71 |
+
set({ isListening: false, error: '' })
|
72 |
+
},
|
73 |
+
clear: () => {
|
74 |
+
set({ transcript: '', error: '' })
|
75 |
+
},
|
76 |
+
}))
|
src/services/plugins/usePlugins.ts
CHANGED
@@ -18,6 +18,7 @@ import { fetchAndRun } from "./fetchAndRun"
|
|
18 |
import { useEditors, useEntityEditor, useProjectEditor, useSegmentEditor } from "../editors"
|
19 |
import { useSimulator } from "../simulator/useSimulator"
|
20 |
import { useIO } from "../io/useIO"
|
|
|
21 |
|
22 |
export const usePlugins = create<PluginsStore>((set, get) => ({
|
23 |
...getDefaultPluginsState(),
|
@@ -49,6 +50,7 @@ export const usePlugins = create<PluginsStore>((set, get) => ({
|
|
49 |
return {
|
50 |
audio: useAudio,
|
51 |
assistant: useAssistant,
|
|
|
52 |
segmentEditor: useSegmentEditor,
|
53 |
entityEditor: useEntityEditor,
|
54 |
projectEditor: useProjectEditor,
|
|
|
18 |
import { useEditors, useEntityEditor, useProjectEditor, useSegmentEditor } from "../editors"
|
19 |
import { useSimulator } from "../simulator/useSimulator"
|
20 |
import { useIO } from "../io/useIO"
|
21 |
+
import { useMic } from "../mic/useMic"
|
22 |
|
23 |
export const usePlugins = create<PluginsStore>((set, get) => ({
|
24 |
...getDefaultPluginsState(),
|
|
|
50 |
return {
|
51 |
audio: useAudio,
|
52 |
assistant: useAssistant,
|
53 |
+
mic: useMic,
|
54 |
segmentEditor: useSegmentEditor,
|
55 |
entityEditor: useEntityEditor,
|
56 |
projectEditor: useProjectEditor,
|
tests/examples.spec.txt
ADDED
@@ -0,0 +1,437 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { test, expect, type Page } from '@playwright/test';
|
2 |
+
|
3 |
+
test.beforeEach(async ({ page }) => {
|
4 |
+
await page.goto('https://demo.playwright.dev/todomvc');
|
5 |
+
});
|
6 |
+
|
7 |
+
const TODO_ITEMS = [
|
8 |
+
'buy some cheese',
|
9 |
+
'feed the cat',
|
10 |
+
'book a doctors appointment'
|
11 |
+
] as const;
|
12 |
+
|
13 |
+
test.describe('New Todo', () => {
|
14 |
+
test('should allow me to add todo items', async ({ page }) => {
|
15 |
+
// create a new todo locator
|
16 |
+
const newTodo = page.getByPlaceholder('What needs to be done?');
|
17 |
+
|
18 |
+
// Create 1st todo.
|
19 |
+
await newTodo.fill(TODO_ITEMS[0]);
|
20 |
+
await newTodo.press('Enter');
|
21 |
+
|
22 |
+
// Make sure the list only has one todo item.
|
23 |
+
await expect(page.getByTestId('todo-title')).toHaveText([
|
24 |
+
TODO_ITEMS[0]
|
25 |
+
]);
|
26 |
+
|
27 |
+
// Create 2nd todo.
|
28 |
+
await newTodo.fill(TODO_ITEMS[1]);
|
29 |
+
await newTodo.press('Enter');
|
30 |
+
|
31 |
+
// Make sure the list now has two todo items.
|
32 |
+
await expect(page.getByTestId('todo-title')).toHaveText([
|
33 |
+
TODO_ITEMS[0],
|
34 |
+
TODO_ITEMS[1]
|
35 |
+
]);
|
36 |
+
|
37 |
+
await checkNumberOfTodosInLocalStorage(page, 2);
|
38 |
+
});
|
39 |
+
|
40 |
+
test('should clear text input field when an item is added', async ({ page }) => {
|
41 |
+
// create a new todo locator
|
42 |
+
const newTodo = page.getByPlaceholder('What needs to be done?');
|
43 |
+
|
44 |
+
// Create one todo item.
|
45 |
+
await newTodo.fill(TODO_ITEMS[0]);
|
46 |
+
await newTodo.press('Enter');
|
47 |
+
|
48 |
+
// Check that input is empty.
|
49 |
+
await expect(newTodo).toBeEmpty();
|
50 |
+
await checkNumberOfTodosInLocalStorage(page, 1);
|
51 |
+
});
|
52 |
+
|
53 |
+
test('should append new items to the bottom of the list', async ({ page }) => {
|
54 |
+
// Create 3 items.
|
55 |
+
await createDefaultTodos(page);
|
56 |
+
|
57 |
+
// create a todo count locator
|
58 |
+
const todoCount = page.getByTestId('todo-count')
|
59 |
+
|
60 |
+
// Check test using different methods.
|
61 |
+
await expect(page.getByText('3 items left')).toBeVisible();
|
62 |
+
await expect(todoCount).toHaveText('3 items left');
|
63 |
+
await expect(todoCount).toContainText('3');
|
64 |
+
await expect(todoCount).toHaveText(/3/);
|
65 |
+
|
66 |
+
// Check all items in one call.
|
67 |
+
await expect(page.getByTestId('todo-title')).toHaveText(TODO_ITEMS);
|
68 |
+
await checkNumberOfTodosInLocalStorage(page, 3);
|
69 |
+
});
|
70 |
+
});
|
71 |
+
|
72 |
+
test.describe('Mark all as completed', () => {
|
73 |
+
test.beforeEach(async ({ page }) => {
|
74 |
+
await createDefaultTodos(page);
|
75 |
+
await checkNumberOfTodosInLocalStorage(page, 3);
|
76 |
+
});
|
77 |
+
|
78 |
+
test.afterEach(async ({ page }) => {
|
79 |
+
await checkNumberOfTodosInLocalStorage(page, 3);
|
80 |
+
});
|
81 |
+
|
82 |
+
test('should allow me to mark all items as completed', async ({ page }) => {
|
83 |
+
// Complete all todos.
|
84 |
+
await page.getByLabel('Mark all as complete').check();
|
85 |
+
|
86 |
+
// Ensure all todos have 'completed' class.
|
87 |
+
await expect(page.getByTestId('todo-item')).toHaveClass(['completed', 'completed', 'completed']);
|
88 |
+
await checkNumberOfCompletedTodosInLocalStorage(page, 3);
|
89 |
+
});
|
90 |
+
|
91 |
+
test('should allow me to clear the complete state of all items', async ({ page }) => {
|
92 |
+
const toggleAll = page.getByLabel('Mark all as complete');
|
93 |
+
// Check and then immediately uncheck.
|
94 |
+
await toggleAll.check();
|
95 |
+
await toggleAll.uncheck();
|
96 |
+
|
97 |
+
// Should be no completed classes.
|
98 |
+
await expect(page.getByTestId('todo-item')).toHaveClass(['', '', '']);
|
99 |
+
});
|
100 |
+
|
101 |
+
test('complete all checkbox should update state when items are completed / cleared', async ({ page }) => {
|
102 |
+
const toggleAll = page.getByLabel('Mark all as complete');
|
103 |
+
await toggleAll.check();
|
104 |
+
await expect(toggleAll).toBeChecked();
|
105 |
+
await checkNumberOfCompletedTodosInLocalStorage(page, 3);
|
106 |
+
|
107 |
+
// Uncheck first todo.
|
108 |
+
const firstTodo = page.getByTestId('todo-item').nth(0);
|
109 |
+
await firstTodo.getByRole('checkbox').uncheck();
|
110 |
+
|
111 |
+
// Reuse toggleAll locator and make sure its not checked.
|
112 |
+
await expect(toggleAll).not.toBeChecked();
|
113 |
+
|
114 |
+
await firstTodo.getByRole('checkbox').check();
|
115 |
+
await checkNumberOfCompletedTodosInLocalStorage(page, 3);
|
116 |
+
|
117 |
+
// Assert the toggle all is checked again.
|
118 |
+
await expect(toggleAll).toBeChecked();
|
119 |
+
});
|
120 |
+
});
|
121 |
+
|
122 |
+
test.describe('Item', () => {
|
123 |
+
|
124 |
+
test('should allow me to mark items as complete', async ({ page }) => {
|
125 |
+
// create a new todo locator
|
126 |
+
const newTodo = page.getByPlaceholder('What needs to be done?');
|
127 |
+
|
128 |
+
// Create two items.
|
129 |
+
for (const item of TODO_ITEMS.slice(0, 2)) {
|
130 |
+
await newTodo.fill(item);
|
131 |
+
await newTodo.press('Enter');
|
132 |
+
}
|
133 |
+
|
134 |
+
// Check first item.
|
135 |
+
const firstTodo = page.getByTestId('todo-item').nth(0);
|
136 |
+
await firstTodo.getByRole('checkbox').check();
|
137 |
+
await expect(firstTodo).toHaveClass('completed');
|
138 |
+
|
139 |
+
// Check second item.
|
140 |
+
const secondTodo = page.getByTestId('todo-item').nth(1);
|
141 |
+
await expect(secondTodo).not.toHaveClass('completed');
|
142 |
+
await secondTodo.getByRole('checkbox').check();
|
143 |
+
|
144 |
+
// Assert completed class.
|
145 |
+
await expect(firstTodo).toHaveClass('completed');
|
146 |
+
await expect(secondTodo).toHaveClass('completed');
|
147 |
+
});
|
148 |
+
|
149 |
+
test('should allow me to un-mark items as complete', async ({ page }) => {
|
150 |
+
// create a new todo locator
|
151 |
+
const newTodo = page.getByPlaceholder('What needs to be done?');
|
152 |
+
|
153 |
+
// Create two items.
|
154 |
+
for (const item of TODO_ITEMS.slice(0, 2)) {
|
155 |
+
await newTodo.fill(item);
|
156 |
+
await newTodo.press('Enter');
|
157 |
+
}
|
158 |
+
|
159 |
+
const firstTodo = page.getByTestId('todo-item').nth(0);
|
160 |
+
const secondTodo = page.getByTestId('todo-item').nth(1);
|
161 |
+
const firstTodoCheckbox = firstTodo.getByRole('checkbox');
|
162 |
+
|
163 |
+
await firstTodoCheckbox.check();
|
164 |
+
await expect(firstTodo).toHaveClass('completed');
|
165 |
+
await expect(secondTodo).not.toHaveClass('completed');
|
166 |
+
await checkNumberOfCompletedTodosInLocalStorage(page, 1);
|
167 |
+
|
168 |
+
await firstTodoCheckbox.uncheck();
|
169 |
+
await expect(firstTodo).not.toHaveClass('completed');
|
170 |
+
await expect(secondTodo).not.toHaveClass('completed');
|
171 |
+
await checkNumberOfCompletedTodosInLocalStorage(page, 0);
|
172 |
+
});
|
173 |
+
|
174 |
+
test('should allow me to edit an item', async ({ page }) => {
|
175 |
+
await createDefaultTodos(page);
|
176 |
+
|
177 |
+
const todoItems = page.getByTestId('todo-item');
|
178 |
+
const secondTodo = todoItems.nth(1);
|
179 |
+
await secondTodo.dblclick();
|
180 |
+
await expect(secondTodo.getByRole('textbox', { name: 'Edit' })).toHaveValue(TODO_ITEMS[1]);
|
181 |
+
await secondTodo.getByRole('textbox', { name: 'Edit' }).fill('buy some sausages');
|
182 |
+
await secondTodo.getByRole('textbox', { name: 'Edit' }).press('Enter');
|
183 |
+
|
184 |
+
// Explicitly assert the new text value.
|
185 |
+
await expect(todoItems).toHaveText([
|
186 |
+
TODO_ITEMS[0],
|
187 |
+
'buy some sausages',
|
188 |
+
TODO_ITEMS[2]
|
189 |
+
]);
|
190 |
+
await checkTodosInLocalStorage(page, 'buy some sausages');
|
191 |
+
});
|
192 |
+
});
|
193 |
+
|
194 |
+
test.describe('Editing', () => {
|
195 |
+
test.beforeEach(async ({ page }) => {
|
196 |
+
await createDefaultTodos(page);
|
197 |
+
await checkNumberOfTodosInLocalStorage(page, 3);
|
198 |
+
});
|
199 |
+
|
200 |
+
test('should hide other controls when editing', async ({ page }) => {
|
201 |
+
const todoItem = page.getByTestId('todo-item').nth(1);
|
202 |
+
await todoItem.dblclick();
|
203 |
+
await expect(todoItem.getByRole('checkbox')).not.toBeVisible();
|
204 |
+
await expect(todoItem.locator('label', {
|
205 |
+
hasText: TODO_ITEMS[1],
|
206 |
+
})).not.toBeVisible();
|
207 |
+
await checkNumberOfTodosInLocalStorage(page, 3);
|
208 |
+
});
|
209 |
+
|
210 |
+
test('should save edits on blur', async ({ page }) => {
|
211 |
+
const todoItems = page.getByTestId('todo-item');
|
212 |
+
await todoItems.nth(1).dblclick();
|
213 |
+
await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).fill('buy some sausages');
|
214 |
+
await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).dispatchEvent('blur');
|
215 |
+
|
216 |
+
await expect(todoItems).toHaveText([
|
217 |
+
TODO_ITEMS[0],
|
218 |
+
'buy some sausages',
|
219 |
+
TODO_ITEMS[2],
|
220 |
+
]);
|
221 |
+
await checkTodosInLocalStorage(page, 'buy some sausages');
|
222 |
+
});
|
223 |
+
|
224 |
+
test('should trim entered text', async ({ page }) => {
|
225 |
+
const todoItems = page.getByTestId('todo-item');
|
226 |
+
await todoItems.nth(1).dblclick();
|
227 |
+
await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).fill(' buy some sausages ');
|
228 |
+
await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).press('Enter');
|
229 |
+
|
230 |
+
await expect(todoItems).toHaveText([
|
231 |
+
TODO_ITEMS[0],
|
232 |
+
'buy some sausages',
|
233 |
+
TODO_ITEMS[2],
|
234 |
+
]);
|
235 |
+
await checkTodosInLocalStorage(page, 'buy some sausages');
|
236 |
+
});
|
237 |
+
|
238 |
+
test('should remove the item if an empty text string was entered', async ({ page }) => {
|
239 |
+
const todoItems = page.getByTestId('todo-item');
|
240 |
+
await todoItems.nth(1).dblclick();
|
241 |
+
await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).fill('');
|
242 |
+
await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).press('Enter');
|
243 |
+
|
244 |
+
await expect(todoItems).toHaveText([
|
245 |
+
TODO_ITEMS[0],
|
246 |
+
TODO_ITEMS[2],
|
247 |
+
]);
|
248 |
+
});
|
249 |
+
|
250 |
+
test('should cancel edits on escape', async ({ page }) => {
|
251 |
+
const todoItems = page.getByTestId('todo-item');
|
252 |
+
await todoItems.nth(1).dblclick();
|
253 |
+
await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).fill('buy some sausages');
|
254 |
+
await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).press('Escape');
|
255 |
+
await expect(todoItems).toHaveText(TODO_ITEMS);
|
256 |
+
});
|
257 |
+
});
|
258 |
+
|
259 |
+
test.describe('Counter', () => {
|
260 |
+
test('should display the current number of todo items', async ({ page }) => {
|
261 |
+
// create a new todo locator
|
262 |
+
const newTodo = page.getByPlaceholder('What needs to be done?');
|
263 |
+
|
264 |
+
// create a todo count locator
|
265 |
+
const todoCount = page.getByTestId('todo-count')
|
266 |
+
|
267 |
+
await newTodo.fill(TODO_ITEMS[0]);
|
268 |
+
await newTodo.press('Enter');
|
269 |
+
|
270 |
+
await expect(todoCount).toContainText('1');
|
271 |
+
|
272 |
+
await newTodo.fill(TODO_ITEMS[1]);
|
273 |
+
await newTodo.press('Enter');
|
274 |
+
await expect(todoCount).toContainText('2');
|
275 |
+
|
276 |
+
await checkNumberOfTodosInLocalStorage(page, 2);
|
277 |
+
});
|
278 |
+
});
|
279 |
+
|
280 |
+
test.describe('Clear completed button', () => {
|
281 |
+
test.beforeEach(async ({ page }) => {
|
282 |
+
await createDefaultTodos(page);
|
283 |
+
});
|
284 |
+
|
285 |
+
test('should display the correct text', async ({ page }) => {
|
286 |
+
await page.locator('.todo-list li .toggle').first().check();
|
287 |
+
await expect(page.getByRole('button', { name: 'Clear completed' })).toBeVisible();
|
288 |
+
});
|
289 |
+
|
290 |
+
test('should remove completed items when clicked', async ({ page }) => {
|
291 |
+
const todoItems = page.getByTestId('todo-item');
|
292 |
+
await todoItems.nth(1).getByRole('checkbox').check();
|
293 |
+
await page.getByRole('button', { name: 'Clear completed' }).click();
|
294 |
+
await expect(todoItems).toHaveCount(2);
|
295 |
+
await expect(todoItems).toHaveText([TODO_ITEMS[0], TODO_ITEMS[2]]);
|
296 |
+
});
|
297 |
+
|
298 |
+
test('should be hidden when there are no items that are completed', async ({ page }) => {
|
299 |
+
await page.locator('.todo-list li .toggle').first().check();
|
300 |
+
await page.getByRole('button', { name: 'Clear completed' }).click();
|
301 |
+
await expect(page.getByRole('button', { name: 'Clear completed' })).toBeHidden();
|
302 |
+
});
|
303 |
+
});
|
304 |
+
|
305 |
+
test.describe('Persistence', () => {
|
306 |
+
test('should persist its data', async ({ page }) => {
|
307 |
+
// create a new todo locator
|
308 |
+
const newTodo = page.getByPlaceholder('What needs to be done?');
|
309 |
+
|
310 |
+
for (const item of TODO_ITEMS.slice(0, 2)) {
|
311 |
+
await newTodo.fill(item);
|
312 |
+
await newTodo.press('Enter');
|
313 |
+
}
|
314 |
+
|
315 |
+
const todoItems = page.getByTestId('todo-item');
|
316 |
+
const firstTodoCheck = todoItems.nth(0).getByRole('checkbox');
|
317 |
+
await firstTodoCheck.check();
|
318 |
+
await expect(todoItems).toHaveText([TODO_ITEMS[0], TODO_ITEMS[1]]);
|
319 |
+
await expect(firstTodoCheck).toBeChecked();
|
320 |
+
await expect(todoItems).toHaveClass(['completed', '']);
|
321 |
+
|
322 |
+
// Ensure there is 1 completed item.
|
323 |
+
await checkNumberOfCompletedTodosInLocalStorage(page, 1);
|
324 |
+
|
325 |
+
// Now reload.
|
326 |
+
await page.reload();
|
327 |
+
await expect(todoItems).toHaveText([TODO_ITEMS[0], TODO_ITEMS[1]]);
|
328 |
+
await expect(firstTodoCheck).toBeChecked();
|
329 |
+
await expect(todoItems).toHaveClass(['completed', '']);
|
330 |
+
});
|
331 |
+
});
|
332 |
+
|
333 |
+
test.describe('Routing', () => {
|
334 |
+
test.beforeEach(async ({ page }) => {
|
335 |
+
await createDefaultTodos(page);
|
336 |
+
// make sure the app had a chance to save updated todos in storage
|
337 |
+
// before navigating to a new view, otherwise the items can get lost :(
|
338 |
+
// in some frameworks like Durandal
|
339 |
+
await checkTodosInLocalStorage(page, TODO_ITEMS[0]);
|
340 |
+
});
|
341 |
+
|
342 |
+
test('should allow me to display active items', async ({ page }) => {
|
343 |
+
const todoItem = page.getByTestId('todo-item');
|
344 |
+
await page.getByTestId('todo-item').nth(1).getByRole('checkbox').check();
|
345 |
+
|
346 |
+
await checkNumberOfCompletedTodosInLocalStorage(page, 1);
|
347 |
+
await page.getByRole('link', { name: 'Active' }).click();
|
348 |
+
await expect(todoItem).toHaveCount(2);
|
349 |
+
await expect(todoItem).toHaveText([TODO_ITEMS[0], TODO_ITEMS[2]]);
|
350 |
+
});
|
351 |
+
|
352 |
+
test('should respect the back button', async ({ page }) => {
|
353 |
+
const todoItem = page.getByTestId('todo-item');
|
354 |
+
await page.getByTestId('todo-item').nth(1).getByRole('checkbox').check();
|
355 |
+
|
356 |
+
await checkNumberOfCompletedTodosInLocalStorage(page, 1);
|
357 |
+
|
358 |
+
await test.step('Showing all items', async () => {
|
359 |
+
await page.getByRole('link', { name: 'All' }).click();
|
360 |
+
await expect(todoItem).toHaveCount(3);
|
361 |
+
});
|
362 |
+
|
363 |
+
await test.step('Showing active items', async () => {
|
364 |
+
await page.getByRole('link', { name: 'Active' }).click();
|
365 |
+
});
|
366 |
+
|
367 |
+
await test.step('Showing completed items', async () => {
|
368 |
+
await page.getByRole('link', { name: 'Completed' }).click();
|
369 |
+
});
|
370 |
+
|
371 |
+
await expect(todoItem).toHaveCount(1);
|
372 |
+
await page.goBack();
|
373 |
+
await expect(todoItem).toHaveCount(2);
|
374 |
+
await page.goBack();
|
375 |
+
await expect(todoItem).toHaveCount(3);
|
376 |
+
});
|
377 |
+
|
378 |
+
test('should allow me to display completed items', async ({ page }) => {
|
379 |
+
await page.getByTestId('todo-item').nth(1).getByRole('checkbox').check();
|
380 |
+
await checkNumberOfCompletedTodosInLocalStorage(page, 1);
|
381 |
+
await page.getByRole('link', { name: 'Completed' }).click();
|
382 |
+
await expect(page.getByTestId('todo-item')).toHaveCount(1);
|
383 |
+
});
|
384 |
+
|
385 |
+
test('should allow me to display all items', async ({ page }) => {
|
386 |
+
await page.getByTestId('todo-item').nth(1).getByRole('checkbox').check();
|
387 |
+
await checkNumberOfCompletedTodosInLocalStorage(page, 1);
|
388 |
+
await page.getByRole('link', { name: 'Active' }).click();
|
389 |
+
await page.getByRole('link', { name: 'Completed' }).click();
|
390 |
+
await page.getByRole('link', { name: 'All' }).click();
|
391 |
+
await expect(page.getByTestId('todo-item')).toHaveCount(3);
|
392 |
+
});
|
393 |
+
|
394 |
+
test('should highlight the currently applied filter', async ({ page }) => {
|
395 |
+
await expect(page.getByRole('link', { name: 'All' })).toHaveClass('selected');
|
396 |
+
|
397 |
+
//create locators for active and completed links
|
398 |
+
const activeLink = page.getByRole('link', { name: 'Active' });
|
399 |
+
const completedLink = page.getByRole('link', { name: 'Completed' });
|
400 |
+
await activeLink.click();
|
401 |
+
|
402 |
+
// Page change - active items.
|
403 |
+
await expect(activeLink).toHaveClass('selected');
|
404 |
+
await completedLink.click();
|
405 |
+
|
406 |
+
// Page change - completed items.
|
407 |
+
await expect(completedLink).toHaveClass('selected');
|
408 |
+
});
|
409 |
+
});
|
410 |
+
|
411 |
+
async function createDefaultTodos(page: Page) {
|
412 |
+
// create a new todo locator
|
413 |
+
const newTodo = page.getByPlaceholder('What needs to be done?');
|
414 |
+
|
415 |
+
for (const item of TODO_ITEMS) {
|
416 |
+
await newTodo.fill(item);
|
417 |
+
await newTodo.press('Enter');
|
418 |
+
}
|
419 |
+
}
|
420 |
+
|
421 |
+
async function checkNumberOfTodosInLocalStorage(page: Page, expected: number) {
|
422 |
+
return await page.waitForFunction(e => {
|
423 |
+
return JSON.parse(localStorage['react-todos']).length === e;
|
424 |
+
}, expected);
|
425 |
+
}
|
426 |
+
|
427 |
+
async function checkNumberOfCompletedTodosInLocalStorage(page: Page, expected: number) {
|
428 |
+
return await page.waitForFunction(e => {
|
429 |
+
return JSON.parse(localStorage['react-todos']).filter((todo: any) => todo.completed).length === e;
|
430 |
+
}, expected);
|
431 |
+
}
|
432 |
+
|
433 |
+
async function checkTodosInLocalStorage(page: Page, title: string) {
|
434 |
+
return await page.waitForFunction(t => {
|
435 |
+
return JSON.parse(localStorage['react-todos']).map((todo: any) => todo.title).includes(t);
|
436 |
+
}, title);
|
437 |
+
}
|
tests/main.spec.ts
ADDED
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { test, expect } from '@playwright/test';
|
2 |
+
|
3 |
+
test('has title', async ({ page }) => {
|
4 |
+
await page.goto('http://localhost:3000/');
|
5 |
+
|
6 |
+
// Expect a title "to contain" a substring.
|
7 |
+
await expect(page).toHaveTitle(/Clapper/);
|
8 |
+
});
|
9 |
+
|
10 |
+
test('get started link', async ({ page }) => {
|
11 |
+
await page.goto('http://localhost:3000/');
|
12 |
+
|
13 |
+
// TODO: replace by our own real tests
|
14 |
+
// Click the get started link.
|
15 |
+
// await page.getByRole('link', { name: 'Get started' }).click();
|
16 |
+
|
17 |
+
// Expects page to have a heading with the name of Installation.
|
18 |
+
// await expect(page.getByRole('heading', { name: 'Installation' })).toBeVisible();
|
19 |
+
});
|
tsconfig.json
CHANGED
@@ -21,6 +21,6 @@
|
|
21 |
"@/*": ["./src/*"]
|
22 |
}
|
23 |
},
|
24 |
-
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
25 |
"exclude": ["node_modules"]
|
26 |
}
|
|
|
21 |
"@/*": ["./src/*"]
|
22 |
}
|
23 |
},
|
24 |
+
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", "vitest.config.mts"],
|
25 |
"exclude": ["node_modules"]
|
26 |
}
|
vitest.config.mts
ADDED
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { defineConfig } from 'vitest/config'
|
2 |
+
import react from '@vitejs/plugin-react'
|
3 |
+
|
4 |
+
export default defineConfig({
|
5 |
+
plugins: [react()],
|
6 |
+
test: {
|
7 |
+
environment: 'jsdom',
|
8 |
+
exclude: [
|
9 |
+
'**/node_modules/**',
|
10 |
+
'**/.next/**',
|
11 |
+
'**/tests/**', // <- we ignore since those are Playwright tests, not Vitest tests
|
12 |
+
],
|
13 |
+
}
|
14 |
+
})
|