refactor: simplify resource queries and layout tests #40
@@ -30,6 +30,8 @@ jobs:
|
|||||||
- run: npm ci
|
- run: npm ci
|
||||||
env:
|
env:
|
||||||
HUSKY: '0'
|
HUSKY: '0'
|
||||||
|
- name: Install Chromium and system dependencies
|
||||||
|
run: npx --no-install playwright install --with-deps chromium
|
||||||
- run: npm run verify
|
- run: npm run verify
|
||||||
env:
|
env:
|
||||||
KNIP_DISABLE_RAW_TRANSFER: '1'
|
KNIP_DISABLE_RAW_TRANSFER: '1'
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ jobs:
|
|||||||
- run: npm ci
|
- run: npm ci
|
||||||
env:
|
env:
|
||||||
HUSKY: '0'
|
HUSKY: '0'
|
||||||
|
- name: Install Chromium and system dependencies
|
||||||
|
run: npx --no-install playwright install --with-deps chromium
|
||||||
- name: Run all frontend gates
|
- name: Run all frontend gates
|
||||||
run: npm run verify
|
run: npm run verify
|
||||||
env:
|
env:
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ dist
|
|||||||
storybook-static
|
storybook-static
|
||||||
dist-ssr
|
dist-ssr
|
||||||
coverage
|
coverage
|
||||||
|
.vitest
|
||||||
.scannerwork
|
.scannerwork
|
||||||
*.local
|
*.local
|
||||||
|
|
||||||
|
|||||||
@@ -75,12 +75,19 @@ Service worker кеширует только статическую оболоч
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
nvm use --silent
|
nvm use --silent
|
||||||
|
npm ci
|
||||||
|
npx --no-install playwright install chromium
|
||||||
npm run verify
|
npm run verify
|
||||||
```
|
```
|
||||||
|
|
||||||
Команда проверяет, что generated OpenAPI types соответствуют committed snapshot,
|
Команда проверяет, что generated OpenAPI types соответствуют committed snapshot,
|
||||||
затем запускает strict TypeScript, Biome, один coverage-backed запуск Vitest,
|
затем запускает strict TypeScript, Biome, один coverage-backed запуск Vitest,
|
||||||
Knip, dependency rules приложения и stories, сборки приложения и Storybook.
|
Knip, dependency rules приложения и stories, сборки приложения и Storybook.
|
||||||
|
Vitest включает JSDOM-тесты и проверку геометрии секций позиций в headless
|
||||||
|
Chromium при ширинах 375 и 1280 px. Browser project не использует JSDOM setup
|
||||||
|
и его заглушки layout API. Только браузерная проверка:
|
||||||
|
`npm run test:run -- --project='browser-*'`. После обновления Playwright повторите
|
||||||
|
установку Chromium; CI устанавливает его с `--with-deps` для Linux.
|
||||||
Отдельный CI workflow `Frontend security` на push, pull request и ежедневно
|
Отдельный CI workflow `Frontend security` на push, pull request и ежедневно
|
||||||
проверяет полную доступную историю Gitleaks 8.30.1 и выполняет
|
проверяет полную доступную историю Gitleaks 8.30.1 и выполняет
|
||||||
`npm audit --include=dev --audit-level=low`. Любой advisory блокирует security job;
|
`npm audit --include=dev --audit-level=low`. Любой advisory блокирует security job;
|
||||||
|
|||||||
Generated
+201
@@ -49,6 +49,7 @@
|
|||||||
"@types/react": "19.3.0",
|
"@types/react": "19.3.0",
|
||||||
"@types/react-dom": "19.3.0",
|
"@types/react-dom": "19.3.0",
|
||||||
"@vitejs/plugin-react": "6.1.1",
|
"@vitejs/plugin-react": "6.1.1",
|
||||||
|
"@vitest/browser-playwright": "5.0.0",
|
||||||
"@vitest/coverage-v8": "5.0.0",
|
"@vitest/coverage-v8": "5.0.0",
|
||||||
"babel-plugin-react-compiler": "1.0.0",
|
"babel-plugin-react-compiler": "1.0.0",
|
||||||
"conventional-changelog-conventionalcommits": "10.4.0",
|
"conventional-changelog-conventionalcommits": "10.4.0",
|
||||||
@@ -59,6 +60,7 @@
|
|||||||
"lint-staged": "17.5.1",
|
"lint-staged": "17.5.1",
|
||||||
"msw": "2.15.0",
|
"msw": "2.15.0",
|
||||||
"openapi-typescript": "7.13.0",
|
"openapi-typescript": "7.13.0",
|
||||||
|
"playwright": "1.63.0",
|
||||||
"release-it": "21.0.2",
|
"release-it": "21.0.2",
|
||||||
"storybook": "10.6.0",
|
"storybook": "10.6.0",
|
||||||
"typescript": "5.9.3",
|
"typescript": "5.9.3",
|
||||||
@@ -853,6 +855,13 @@
|
|||||||
"node": ">=14.21.3"
|
"node": ">=14.21.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@blazediff/core": {
|
||||||
|
"version": "1.10.0",
|
||||||
|
"resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/@blazediff/core/-/core-1.10.0.tgz",
|
||||||
|
"integrity": "sha512-AOQff0zgR7cGsZL+4E7hVkmujoPUpm0J9xzWGWZj5wCjd3gmxESXAPfKyuzs93VdpQNFhHlBhfOjrcZ+XTERtQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@bramus/specificity": {
|
"node_modules/@bramus/specificity": {
|
||||||
"version": "2.4.2",
|
"version": "2.4.2",
|
||||||
"resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
|
"resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
|
||||||
@@ -3732,6 +3741,13 @@
|
|||||||
"url": "https://github.com/phun-ky/typeof?sponsor=1"
|
"url": "https://github.com/phun-ky/typeof?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@polka/url": {
|
||||||
|
"version": "1.0.0-next.29",
|
||||||
|
"resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/@polka/url/-/url-1.0.0-next.29.tgz",
|
||||||
|
"integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@redocly/ajv": {
|
"node_modules/@redocly/ajv": {
|
||||||
"version": "8.11.2",
|
"version": "8.11.2",
|
||||||
"resolved": "https://registry.npmjs.org/@redocly/ajv/-/ajv-8.11.2.tgz",
|
"resolved": "https://registry.npmjs.org/@redocly/ajv/-/ajv-8.11.2.tgz",
|
||||||
@@ -5376,6 +5392,54 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@vitest/browser": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/@vitest/browser/-/browser-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-JC9FG5xIRxPHXJPcdCaluIJcEoeM0IwGQ3xneuJk09LXKHRNs40BqWDWymQikbb20yOpYvzpKzmYgPRuSzKmvg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@blazediff/core": "1.10.0",
|
||||||
|
"@vitest/mocker": "5.0.0",
|
||||||
|
"@vitest/ui": "5.0.0",
|
||||||
|
"@vitest/utils": "5.0.0",
|
||||||
|
"magic-string": "^1.2.3",
|
||||||
|
"pngjs": "^7.0.0",
|
||||||
|
"sirv": "^3.0.2",
|
||||||
|
"tinyrainbow": "^3.1.1",
|
||||||
|
"ws": "^8.21.3"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"vitest": "5.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vitest/browser-playwright": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/@vitest/browser-playwright/-/browser-playwright-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-N+gED9y4/8pypaHjz/x0ah3CjoBr+N0hWWT+Gq4VXtMzyB+rUIdHni0ulzpD1j4H77iWy5Jg8PRMlVn6kjxo6g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@vitest/browser": "5.0.0",
|
||||||
|
"@vitest/mocker": "5.0.0",
|
||||||
|
"tinyrainbow": "^3.1.1"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"playwright": "*",
|
||||||
|
"vitest": "5.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"playwright": {
|
||||||
|
"optional": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@vitest/coverage-v8": {
|
"node_modules/@vitest/coverage-v8": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-5.0.0.tgz",
|
||||||
@@ -5456,6 +5520,19 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@vitest/pretty-format": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/@vitest/pretty-format/-/pretty-format-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-PVRNuB3wpReb4SQEs4zTKM4KWFhQ5pw3spE8naoDJNB5T5aWRzGKHwXcLUllr0WeOTXpB6bSr3CJLo5+7XQSSQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tinyrainbow": "^3.1.1"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@vitest/spy": {
|
"node_modules/@vitest/spy": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.0.tgz",
|
||||||
@@ -5466,6 +5543,42 @@
|
|||||||
"url": "https://opencollective.com/vitest"
|
"url": "https://opencollective.com/vitest"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@vitest/ui": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/@vitest/ui/-/ui-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-h2FIFwggCY2GxUd2UdQoYNVQkOIqEQLPhNREcl3FUiRsdzQep7NWwYbSmhGEA9nFLPDq5pXzRMcBZQU8Py83sg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@vitest/utils": "5.0.0",
|
||||||
|
"fflate": "^0.8.3",
|
||||||
|
"flatted": "^3.4.4",
|
||||||
|
"pathe": "^2.0.3",
|
||||||
|
"sirv": "^3.0.2",
|
||||||
|
"tinyrainbow": "^3.1.1"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"vitest": "5.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vitest/utils": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/@vitest/utils/-/utils-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-dO++xL3vDfvhTAVimfkuQUA3k+JClIF1i1vAkPqpcGAthRmeWnXmHB7YPViPvgCwviX8u7Y5W1u2N//AaQr3fw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@vitest/pretty-format": "5.0.0",
|
||||||
|
"convert-source-map": "^2.0.0",
|
||||||
|
"tinyrainbow": "^3.1.1"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@wealthledger/icons": {
|
"node_modules/@wealthledger/icons": {
|
||||||
"resolved": "packages/icons",
|
"resolved": "packages/icons",
|
||||||
"link": true
|
"link": true
|
||||||
@@ -7606,6 +7719,13 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/fflate": {
|
||||||
|
"version": "0.8.3",
|
||||||
|
"resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/fflate/-/fflate-0.8.3.tgz",
|
||||||
|
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/filelist": {
|
"node_modules/filelist": {
|
||||||
"version": "1.0.6",
|
"version": "1.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz",
|
||||||
@@ -7646,6 +7766,13 @@
|
|||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/flatted": {
|
||||||
|
"version": "3.4.4",
|
||||||
|
"resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/flatted/-/flatted-3.4.4.tgz",
|
||||||
|
"integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/for-each": {
|
"node_modules/for-each": {
|
||||||
"version": "0.3.5",
|
"version": "0.3.5",
|
||||||
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
|
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
|
||||||
@@ -9946,6 +10073,16 @@
|
|||||||
"integrity": "sha512-7DnN7TmbLcYXcG4RVadXIihWlyuM9afoUww8Y5Agg431kGKiuL2/OMyP4mJ5wLz+pvN3t5ySClLOaVXJ+wekRQ==",
|
"integrity": "sha512-7DnN7TmbLcYXcG4RVadXIihWlyuM9afoUww8Y5Agg431kGKiuL2/OMyP4mJ5wLz+pvN3t5ySClLOaVXJ+wekRQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/mrmime": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/mrmime/-/mrmime-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ms": {
|
"node_modules/ms": {
|
||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
@@ -10749,6 +10886,35 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/playwright": {
|
||||||
|
"version": "1.63.0",
|
||||||
|
"resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/playwright/-/playwright-1.63.0.tgz",
|
||||||
|
"integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright-core": "1.63.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright-core": {
|
||||||
|
"version": "1.63.0",
|
||||||
|
"resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/playwright-core/-/playwright-core-1.63.0.tgz",
|
||||||
|
"integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"playwright-core": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/pluralize": {
|
"node_modules/pluralize": {
|
||||||
"version": "8.0.0",
|
"version": "8.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz",
|
||||||
@@ -10759,6 +10925,16 @@
|
|||||||
"node": ">=4"
|
"node": ">=4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/pngjs": {
|
||||||
|
"version": "7.0.0",
|
||||||
|
"resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/pngjs/-/pngjs-7.0.0.tgz",
|
||||||
|
"integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.19.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/possible-typed-array-names": {
|
"node_modules/possible-typed-array-names": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
|
||||||
@@ -12172,6 +12348,21 @@
|
|||||||
"url": "https://github.com/sponsors/isaacs"
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/sirv": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/sirv/-/sirv-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@polka/url": "^1.0.0-next.24",
|
||||||
|
"mrmime": "^2.0.0",
|
||||||
|
"totalist": "^3.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/sisteransi": {
|
"node_modules/sisteransi": {
|
||||||
"version": "1.0.5",
|
"version": "1.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz",
|
||||||
@@ -13643,6 +13834,16 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/totalist": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/totalist/-/totalist-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/tough-cookie": {
|
"node_modules/tough-cookie": {
|
||||||
"version": "6.0.2",
|
"version": "6.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz",
|
||||||
|
|||||||
@@ -72,6 +72,7 @@
|
|||||||
"@types/react": "19.3.0",
|
"@types/react": "19.3.0",
|
||||||
"@types/react-dom": "19.3.0",
|
"@types/react-dom": "19.3.0",
|
||||||
"@vitejs/plugin-react": "6.1.1",
|
"@vitejs/plugin-react": "6.1.1",
|
||||||
|
"@vitest/browser-playwright": "5.0.0",
|
||||||
"@vitest/coverage-v8": "5.0.0",
|
"@vitest/coverage-v8": "5.0.0",
|
||||||
"babel-plugin-react-compiler": "1.0.0",
|
"babel-plugin-react-compiler": "1.0.0",
|
||||||
"conventional-changelog-conventionalcommits": "10.4.0",
|
"conventional-changelog-conventionalcommits": "10.4.0",
|
||||||
@@ -82,6 +83,7 @@
|
|||||||
"lint-staged": "17.5.1",
|
"lint-staged": "17.5.1",
|
||||||
"msw": "2.15.0",
|
"msw": "2.15.0",
|
||||||
"openapi-typescript": "7.13.0",
|
"openapi-typescript": "7.13.0",
|
||||||
|
"playwright": "1.63.0",
|
||||||
"release-it": "21.0.2",
|
"release-it": "21.0.2",
|
||||||
"storybook": "10.6.0",
|
"storybook": "10.6.0",
|
||||||
"typescript": "5.9.3",
|
"typescript": "5.9.3",
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
} from "./useBrokerageOperations";
|
} from "./useBrokerageOperations";
|
||||||
|
|
||||||
describe("useBrokerageOperations", () => {
|
describe("useBrokerageOperations", () => {
|
||||||
it("normalizes query parameters and loads a page without retries", async () => {
|
it("normalizes categories, preserves the cursor and loads a page", async () => {
|
||||||
configureAuthClient({
|
configureAuthClient({
|
||||||
accessToken: () => "token",
|
accessToken: () => "token",
|
||||||
refresh: async () => true,
|
refresh: async () => true,
|
||||||
|
|||||||
@@ -79,8 +79,49 @@ describe("persistent resource query lifecycle", () => {
|
|||||||
await waitFor(() => expect(result.current.meta?.sync.state).toBe("failed"));
|
await waitFor(() => expect(result.current.meta?.sync.state).toBe("failed"));
|
||||||
expect(result.current.data).toBeUndefined();
|
expect(result.current.data).toBeUndefined();
|
||||||
expect(result.current.isSuccess).toBe(false);
|
expect(result.current.isSuccess).toBe(false);
|
||||||
|
expect(result.current.status).toBe("pending");
|
||||||
|
expect(result.current.isPending).toBe(true);
|
||||||
|
expect(result.current.isLoading).toBe(false);
|
||||||
|
expect(result.current.transport).toEqual({
|
||||||
|
status: "success",
|
||||||
|
fetchStatus: "idle",
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
expect(result.current.meta?.availability).toBe("missing");
|
expect(result.current.meta?.availability).toBe("missing");
|
||||||
});
|
});
|
||||||
|
it("keeps the refetch envelope while exposing the refreshed resource payload", async () => {
|
||||||
|
auth();
|
||||||
|
let ready = false;
|
||||||
|
const refreshed = resource(snapshot, { stateVersion: "ready" });
|
||||||
|
server.use(
|
||||||
|
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
|
||||||
|
HttpResponse.json(ready ? refreshed : resource(null)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const { result } = renderHook(() => usePortfolio("a"), {
|
||||||
|
wrapper: createQueryWrapper().wrapper,
|
||||||
|
});
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(result.current.transport.status).toBe("success"),
|
||||||
|
);
|
||||||
|
await act(async () => {
|
||||||
|
const missing = await result.current.refetch();
|
||||||
|
expect(missing.status).toBe("success");
|
||||||
|
expect(missing.data).toEqual(resource(null));
|
||||||
|
expect(missing.data?.data).toBeNull();
|
||||||
|
ready = true;
|
||||||
|
const loaded = await result.current.refetch();
|
||||||
|
expect(loaded.data).toEqual(refreshed);
|
||||||
|
expect(loaded.data?.data?.totalsByCurrency).toEqual(
|
||||||
|
snapshot.totalsByCurrency,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await waitFor(() => expect(result.current.data).toEqual(snapshot));
|
||||||
|
expect(result.current.meta).toEqual(refreshed.meta);
|
||||||
|
expect(result.current.status).toBe("success");
|
||||||
|
expect(result.current.isSuccess).toBe(true);
|
||||||
|
expect(result.current.isPending).toBe(false);
|
||||||
|
});
|
||||||
it("rejects a late ordinary response when an observe response for that resource arrived first", async () => {
|
it("rejects a late ordinary response when an observe response for that resource arrived first", async () => {
|
||||||
auth();
|
auth();
|
||||||
let release!: () => void;
|
let release!: () => void;
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { server } from "@/test/server";
|
|||||||
import { usePortfolio } from "./usePortfolio";
|
import { usePortfolio } from "./usePortfolio";
|
||||||
|
|
||||||
describe("usePortfolio", () => {
|
describe("usePortfolio", () => {
|
||||||
it("uses the account endpoint with a 30 second cache and no retry", async () => {
|
it("loads the account portfolio", async () => {
|
||||||
configureAuthClient({
|
configureAuthClient({
|
||||||
accessToken: () => "token",
|
accessToken: () => "token",
|
||||||
refresh: async () => true,
|
refresh: async () => true,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export { section } from "./model/section";
|
export { section } from "./model/section";
|
||||||
|
export { useInstrumentOperations } from "./model/useInstrumentOperations";
|
||||||
export { InstrumentChart } from "./ui/InstrumentChart.view";
|
export { InstrumentChart } from "./ui/InstrumentChart.view";
|
||||||
export { InstrumentEvents } from "./ui/InstrumentEvents.view";
|
export { InstrumentEvents } from "./ui/InstrumentEvents.view";
|
||||||
export { InstrumentPosition } from "./ui/InstrumentPosition.view";
|
export { InstrumentPosition } from "./ui/InstrumentPosition.view";
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
brokerageOperationsQueryOptions,
|
||||||
|
type OperationCategory,
|
||||||
|
useBrokerageOperations,
|
||||||
|
} from "@/entities/broker-operation";
|
||||||
|
import { apiErrorCode } from "@/shared/api";
|
||||||
|
|
||||||
|
function useInstrumentOperations(
|
||||||
|
uid: string,
|
||||||
|
filter: string,
|
||||||
|
categories: OperationCategory[],
|
||||||
|
) {
|
||||||
|
const scope = `${uid}:${filter}`;
|
||||||
|
const [pagination, setPagination] = useState<{
|
||||||
|
scope: string;
|
||||||
|
cursors: string[];
|
||||||
|
}>({ scope, cursors: [] });
|
||||||
|
const cursors = pagination.scope === scope ? pagination.cursors : [];
|
||||||
|
const client = useQueryClient();
|
||||||
|
const firstPage = { instrumentUid: uid, categories, limit: 20 };
|
||||||
|
const operations = useBrokerageOperations(uid ? "all" : undefined, {
|
||||||
|
...firstPage,
|
||||||
|
cursor: cursors.at(-1),
|
||||||
|
});
|
||||||
|
const retry = (retryScreen: () => unknown) => {
|
||||||
|
if (apiErrorCode(operations.error) === "INVALID_OPERATIONS_CURSOR") {
|
||||||
|
setPagination({ scope, cursors: [] });
|
||||||
|
void client.invalidateQueries({
|
||||||
|
queryKey: brokerageOperationsQueryOptions("all", firstPage).queryKey,
|
||||||
|
exact: true,
|
||||||
|
});
|
||||||
|
} else void retryScreen();
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
operations,
|
||||||
|
retry,
|
||||||
|
hasPrevious: cursors.length > 0,
|
||||||
|
onPrevious: () => setPagination({ scope, cursors: cursors.slice(0, -1) }),
|
||||||
|
onNext: () => {
|
||||||
|
if (operations.data?.nextCursor)
|
||||||
|
setPagination({
|
||||||
|
scope,
|
||||||
|
cursors: [...cursors, operations.data.nextCursor],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export { useInstrumentOperations };
|
||||||
@@ -660,26 +660,6 @@ describe("AccountPage states", () => {
|
|||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders the bonds section as a single-column grid on any screen width", () => {
|
|
||||||
renderPage(
|
|
||||||
<AccountPageLoaded
|
|
||||||
{...loadedProps({
|
|
||||||
...portfolio,
|
|
||||||
positions: [portfolio.positions[1]],
|
|
||||||
})}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
const singleColumnGrids = Array.from(document.querySelectorAll("style"))
|
|
||||||
.map((style) => (style.textContent ?? "").replace(/\s/g, ""))
|
|
||||||
.filter(
|
|
||||||
(css) =>
|
|
||||||
css.includes("--sg-cols:1") &&
|
|
||||||
!css.includes("@media") &&
|
|
||||||
!/--sg-cols:[23]/.test(css),
|
|
||||||
);
|
|
||||||
expect(singleColumnGrids).toHaveLength(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps other-position cards compact with labeled return and no logo", () => {
|
it("keeps other-position cards compact with labeled return and no logo", () => {
|
||||||
renderPage(
|
renderPage(
|
||||||
<AccountPageLoaded
|
<AccountPageLoaded
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import "@mantine/core/styles.css";
|
||||||
|
import "@wealthledger/ui/styles.css";
|
||||||
|
import "@/shared/i18n";
|
||||||
|
import { MantineProvider } from "@mantine/core";
|
||||||
|
import { cleanup, render, screen } from "@testing-library/react";
|
||||||
|
import { cssVariablesResolver, theme } from "@wealthledger/ui/theme";
|
||||||
|
import { afterEach, expect, it } from "vitest";
|
||||||
|
import type {
|
||||||
|
AccountPagePosition,
|
||||||
|
AccountPagePositionSection,
|
||||||
|
} from "./AccountPage.vm";
|
||||||
|
import { PositionSection } from "./components/PositionSection.view";
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
it("keeps expanded positions vertical while other positions adapt to the viewport", () => {
|
||||||
|
const kinds: AccountPagePositionSection["kind"][] = [
|
||||||
|
"stocks",
|
||||||
|
"bonds",
|
||||||
|
"other",
|
||||||
|
];
|
||||||
|
const width = window.innerWidth;
|
||||||
|
expect([375, 1280]).toContain(width);
|
||||||
|
for (const kind of kinds) {
|
||||||
|
const positions: AccountPagePosition[] = [1, 2, 3].map((index) => ({
|
||||||
|
figi: null,
|
||||||
|
displayName: `Инструмент ${index}`,
|
||||||
|
type: kind === "stocks" ? "STOCK" : kind === "bonds" ? "BOND" : "FUND",
|
||||||
|
quantity: "10",
|
||||||
|
currentValue: { currency: "RUB", value: "1000" },
|
||||||
|
averagePurchasePrice: null,
|
||||||
|
absoluteReturn: null,
|
||||||
|
isin: null,
|
||||||
|
ticker: null,
|
||||||
|
logoName: null,
|
||||||
|
shareText: null,
|
||||||
|
returnText: null,
|
||||||
|
logoSrc: null,
|
||||||
|
}));
|
||||||
|
const { unmount } = render(
|
||||||
|
<MantineProvider
|
||||||
|
theme={theme}
|
||||||
|
cssVariablesResolver={cssVariablesResolver}
|
||||||
|
forceColorScheme="light"
|
||||||
|
>
|
||||||
|
<PositionSection
|
||||||
|
section={{
|
||||||
|
kind,
|
||||||
|
groups: positions.map((position) => ({
|
||||||
|
key: position.displayName,
|
||||||
|
positions: [position],
|
||||||
|
})),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</MantineProvider>,
|
||||||
|
);
|
||||||
|
const bounds = positions.map((position) =>
|
||||||
|
screen.getByText(position.displayName).getBoundingClientRect(),
|
||||||
|
);
|
||||||
|
const context = `${kind} at ${width}px`;
|
||||||
|
for (const [index, current] of bounds.entries()) {
|
||||||
|
expect(current.width, context).toBeGreaterThan(0);
|
||||||
|
expect(current.height, context).toBeGreaterThan(0);
|
||||||
|
if (index === 0) continue;
|
||||||
|
const previous = bounds[index - 1];
|
||||||
|
if (kind === "other" && width === 1280) {
|
||||||
|
expect(current.top, context).toBeCloseTo(previous.top, 0);
|
||||||
|
expect(current.left, context).toBeGreaterThan(previous.right);
|
||||||
|
} else {
|
||||||
|
expect(current.left, context).toBeCloseTo(previous.left, 0);
|
||||||
|
expect(current.top, context).toBeGreaterThan(previous.bottom);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
unmount();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
import { useQueryClient } from "@tanstack/react-query";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { useParams, useSearchParams } from "react-router";
|
import { useParams, useSearchParams } from "react-router";
|
||||||
import {
|
import {
|
||||||
type BondPeriod,
|
type BondPeriod,
|
||||||
@@ -10,12 +8,11 @@ import {
|
|||||||
useBondPosition,
|
useBondPosition,
|
||||||
useBondQuote,
|
useBondQuote,
|
||||||
} from "@/entities/bond";
|
} from "@/entities/bond";
|
||||||
|
import type { OperationCategory } from "@/entities/broker-operation";
|
||||||
import {
|
import {
|
||||||
brokerageOperationsQueryOptions,
|
section,
|
||||||
type OperationCategory,
|
useInstrumentOperations,
|
||||||
useBrokerageOperations,
|
} from "@/features/instrument-details";
|
||||||
} from "@/entities/broker-operation";
|
|
||||||
import { section } from "@/features/instrument-details";
|
|
||||||
import { useScreenSync } from "@/features/tbank-connection";
|
import { useScreenSync } from "@/features/tbank-connection";
|
||||||
import { apiErrorCode } from "@/shared/api";
|
import { apiErrorCode } from "@/shared/api";
|
||||||
import { formatDate, instrumentLogoUrl } from "@/shared/lib";
|
import { formatDate, instrumentLogoUrl } from "@/shared/lib";
|
||||||
@@ -38,27 +35,13 @@ function useBondPageVm() {
|
|||||||
periods.find((value) => value === params.get("period")) ?? "month";
|
periods.find((value) => value === params.get("period")) ?? "month";
|
||||||
const filter =
|
const filter =
|
||||||
filters.find((value) => value === params.get("events")) ?? "all";
|
filters.find((value) => value === params.get("events")) ?? "all";
|
||||||
const scope = `${uid}:${filter}`;
|
|
||||||
const [pagination, setPagination] = useState<{
|
|
||||||
scope: string;
|
|
||||||
cursors: string[];
|
|
||||||
}>({ scope, cursors: [] });
|
|
||||||
const cursors = pagination.scope === scope ? pagination.cursors : [];
|
|
||||||
const client = useQueryClient();
|
|
||||||
const quote = useBondQuote(uid);
|
const quote = useBondQuote(uid);
|
||||||
const metrics = useBondMetrics(uid);
|
const metrics = useBondMetrics(uid);
|
||||||
const candles = useBondCandles(uid, period);
|
const candles = useBondCandles(uid, period);
|
||||||
const position = useBondPosition(uid);
|
const position = useBondPosition(uid);
|
||||||
const coupons = useBondCoupons(uid);
|
const coupons = useBondCoupons(uid);
|
||||||
const firstPage = {
|
const history = useInstrumentOperations(uid, filter, categories[filter]);
|
||||||
instrumentUid: uid,
|
const { operations } = history;
|
||||||
categories: categories[filter],
|
|
||||||
limit: 20,
|
|
||||||
};
|
|
||||||
const operations = useBrokerageOperations(uid ? "all" : undefined, {
|
|
||||||
...firstPage,
|
|
||||||
cursor: cursors.at(-1),
|
|
||||||
});
|
|
||||||
const sync = useScreenSync(
|
const sync = useScreenSync(
|
||||||
{
|
{
|
||||||
screen: "bond",
|
screen: "bond",
|
||||||
@@ -75,15 +58,6 @@ function useBondPageVm() {
|
|||||||
next.set(key, value);
|
next.set(key, value);
|
||||||
setParams(next, { replace: true });
|
setParams(next, { replace: true });
|
||||||
};
|
};
|
||||||
const retryOperations = () => {
|
|
||||||
if (apiErrorCode(operations.error) === "INVALID_OPERATIONS_CURSOR") {
|
|
||||||
setPagination({ scope, cursors: [] });
|
|
||||||
void client.invalidateQueries({
|
|
||||||
queryKey: brokerageOperationsQueryOptions("all", firstPage).queryKey,
|
|
||||||
exact: true,
|
|
||||||
});
|
|
||||||
} else void sync.retry();
|
|
||||||
};
|
|
||||||
const chartData =
|
const chartData =
|
||||||
candles.data?.candles.map((candle) => ({
|
candles.data?.candles.map((candle) => ({
|
||||||
...candle,
|
...candle,
|
||||||
@@ -138,7 +112,7 @@ function useBondPageVm() {
|
|||||||
operations: operations.data,
|
operations: operations.data,
|
||||||
operationsState: {
|
operationsState: {
|
||||||
...section(operations, sync.retry),
|
...section(operations, sync.retry),
|
||||||
retry: retryOperations,
|
retry: () => history.retry(sync.retry),
|
||||||
},
|
},
|
||||||
eventRows:
|
eventRows:
|
||||||
operations.data?.items.map((item) => ({
|
operations.data?.items.map((item) => ({
|
||||||
@@ -148,15 +122,9 @@ function useBondPageVm() {
|
|||||||
})) ?? [],
|
})) ?? [],
|
||||||
filter,
|
filter,
|
||||||
setFilter: (value: string) => setParam("events", value),
|
setFilter: (value: string) => setParam("events", value),
|
||||||
hasPrevious: cursors.length > 0,
|
hasPrevious: history.hasPrevious,
|
||||||
onPrevious: () => setPagination({ scope, cursors: cursors.slice(0, -1) }),
|
onPrevious: history.onPrevious,
|
||||||
onNext: () => {
|
onNext: history.onNext,
|
||||||
if (operations.data?.nextCursor)
|
|
||||||
setPagination({
|
|
||||||
scope,
|
|
||||||
cursors: [...cursors, operations.data.nextCursor],
|
|
||||||
});
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
type BondPageVm = ReturnType<typeof useBondPageVm>;
|
type BondPageVm = ReturnType<typeof useBondPageVm>;
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ function setup(
|
|||||||
noPosition?: boolean;
|
noPosition?: boolean;
|
||||||
failCompany?: boolean;
|
failCompany?: boolean;
|
||||||
failStock?: boolean;
|
failStock?: boolean;
|
||||||
|
cursorError?: string;
|
||||||
path?: string;
|
path?: string;
|
||||||
slug?: string;
|
slug?: string;
|
||||||
ambiguousTicker?: boolean;
|
ambiguousTicker?: boolean;
|
||||||
@@ -79,6 +80,8 @@ function setup(
|
|||||||
const requests: URL[] = [];
|
const requests: URL[] = [];
|
||||||
const resolved = { ...stock, slug: options.slug ?? uid };
|
const resolved = { ...stock, slug: options.slug ?? uid };
|
||||||
let failQuote = false;
|
let failQuote = false;
|
||||||
|
let cursorError = options.cursorError;
|
||||||
|
let operationPayment = "200";
|
||||||
server.use(
|
server.use(
|
||||||
http.get("*/api/v1/stocks/:uid", () =>
|
http.get("*/api/v1/stocks/:uid", () =>
|
||||||
options.failStock
|
options.failStock
|
||||||
@@ -166,6 +169,8 @@ function setup(
|
|||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
requests.push(url);
|
requests.push(url);
|
||||||
const next = url.searchParams.has("cursor");
|
const next = url.searchParams.has("cursor");
|
||||||
|
if (next && cursorError)
|
||||||
|
return HttpResponse.json({ code: cursorError }, { status: 400 });
|
||||||
return HttpResponse.json(
|
return HttpResponse.json(
|
||||||
resource({
|
resource({
|
||||||
accountId: "all",
|
accountId: "all",
|
||||||
@@ -182,7 +187,7 @@ function setup(
|
|||||||
category: next ? "trade" : "income",
|
category: next ? "trade" : "income",
|
||||||
description: "",
|
description: "",
|
||||||
account: { id: "main", name: "Основной" },
|
account: { id: "main", name: "Основной" },
|
||||||
payment: money("200"),
|
payment: money(operationPayment),
|
||||||
quantity: next ? "2" : null,
|
quantity: next ? "2" : null,
|
||||||
price: next ? money("100") : null,
|
price: next ? money("100") : null,
|
||||||
},
|
},
|
||||||
@@ -211,6 +216,10 @@ function setup(
|
|||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
requests,
|
requests,
|
||||||
|
recoverOperations: () => {
|
||||||
|
cursorError = undefined;
|
||||||
|
operationPayment = "201";
|
||||||
|
},
|
||||||
failQuote: () => {
|
failQuote: () => {
|
||||||
failQuote = true;
|
failQuote = true;
|
||||||
},
|
},
|
||||||
@@ -374,7 +383,7 @@ describe("stock page", () => {
|
|||||||
expect(requests).toHaveLength(previousRequests);
|
expect(requests).toHaveLength(previousRequests);
|
||||||
expect(modes).toEqual(["observe"]);
|
expect(modes).toEqual(["observe"]);
|
||||||
});
|
});
|
||||||
it("sends instrument-filtered continuation, resets it on filter changes and changes chart period", async () => {
|
it("sends instrument-filtered continuation, restores it on returning to the filter and changes chart period", async () => {
|
||||||
const { requests } = setup();
|
const { requests } = setup();
|
||||||
await screen.findByText("Зачисление дивидендов");
|
await screen.findByText("Зачисление дивидендов");
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Далее" }));
|
fireEvent.click(screen.getByRole("button", { name: "Далее" }));
|
||||||
@@ -399,6 +408,14 @@ describe("stock page", () => {
|
|||||||
),
|
),
|
||||||
).toBe(true),
|
).toBe(true),
|
||||||
);
|
);
|
||||||
|
expect(await screen.findByText("Зачисление дивидендов")).toBeVisible();
|
||||||
|
expect(screen.getByRole("button", { name: "Назад" })).toBeDisabled();
|
||||||
|
fireEvent.click(screen.getByRole("radio", { name: "Все" }));
|
||||||
|
expect(await screen.findByText("Продажа")).toBeVisible();
|
||||||
|
expect(screen.getByRole("button", { name: "Назад" })).toBeEnabled();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Назад" }));
|
||||||
|
expect(await screen.findByText("Зачисление дивидендов")).toBeVisible();
|
||||||
|
expect(screen.getByRole("button", { name: "Назад" })).toBeDisabled();
|
||||||
fireEvent.click(screen.getByRole("radio", { name: "Год" }));
|
fireEvent.click(screen.getByRole("radio", { name: "Год" }));
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(
|
expect(
|
||||||
@@ -406,6 +423,34 @@ describe("stock page", () => {
|
|||||||
).toBe(true),
|
).toBe(true),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
it.each([
|
||||||
|
["INVALID_OPERATIONS_CURSOR", null, null],
|
||||||
|
["TBANK_UNAVAILABLE", "next", "observe"],
|
||||||
|
])(
|
||||||
|
"recovers history after %s with fresh data and the correct cursor",
|
||||||
|
async (code, cursor, sync) => {
|
||||||
|
const controls = setup({ cursorError: code });
|
||||||
|
await screen.findByText("Зачисление дивидендов");
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Далее" }));
|
||||||
|
const retry = await screen.findByRole("button", { name: "Повторить" });
|
||||||
|
controls.recoverOperations();
|
||||||
|
const previousRequests = controls.requests.length;
|
||||||
|
fireEvent.click(retry);
|
||||||
|
expect(await screen.findByText("+201 ₽")).toBeVisible();
|
||||||
|
const recovered = controls.requests.slice(previousRequests);
|
||||||
|
expect(recovered).toHaveLength(1);
|
||||||
|
expect(recovered[0]?.pathname).toBe(
|
||||||
|
"/api/v1/brokerage-accounts/all/operations",
|
||||||
|
);
|
||||||
|
expect(recovered[0]?.searchParams.get("instrumentUid")).toBe(uid);
|
||||||
|
expect(recovered[0]?.searchParams.get("limit")).toBe("20");
|
||||||
|
expect(recovered[0]?.searchParams.get("cursor")).toBe(cursor);
|
||||||
|
expect(recovered[0]?.searchParams.get("sync")).toBe(sync);
|
||||||
|
if (cursor)
|
||||||
|
expect(screen.getByRole("button", { name: "Назад" })).toBeEnabled();
|
||||||
|
else expect(screen.getByRole("button", { name: "Назад" })).toBeDisabled();
|
||||||
|
},
|
||||||
|
);
|
||||||
it("keeps the last quote on a refresh failure", async () => {
|
it("keeps the last quote on a refresh failure", async () => {
|
||||||
const scopes: unknown[] = [];
|
const scopes: unknown[] = [];
|
||||||
server.use(
|
server.use(
|
||||||
|
|||||||
@@ -1,11 +1,5 @@
|
|||||||
import { useQueryClient } from "@tanstack/react-query";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { useParams, useSearchParams } from "react-router";
|
import { useParams, useSearchParams } from "react-router";
|
||||||
import {
|
import type { OperationCategory } from "@/entities/broker-operation";
|
||||||
brokerageOperationsQueryOptions,
|
|
||||||
type OperationCategory,
|
|
||||||
useBrokerageOperations,
|
|
||||||
} from "@/entities/broker-operation";
|
|
||||||
import {
|
import {
|
||||||
type StockPeriod,
|
type StockPeriod,
|
||||||
useStock,
|
useStock,
|
||||||
@@ -15,7 +9,10 @@ import {
|
|||||||
useStockPosition,
|
useStockPosition,
|
||||||
useStockQuote,
|
useStockQuote,
|
||||||
} from "@/entities/stock";
|
} from "@/entities/stock";
|
||||||
import { section } from "@/features/instrument-details";
|
import {
|
||||||
|
section,
|
||||||
|
useInstrumentOperations,
|
||||||
|
} from "@/features/instrument-details";
|
||||||
import { useScreenSync } from "@/features/tbank-connection";
|
import { useScreenSync } from "@/features/tbank-connection";
|
||||||
import { apiErrorCode } from "@/shared/api";
|
import { apiErrorCode } from "@/shared/api";
|
||||||
import { formatDate, instrumentLogoUrl } from "@/shared/lib";
|
import { formatDate, instrumentLogoUrl } from "@/shared/lib";
|
||||||
@@ -38,27 +35,13 @@ function useStockPageVm() {
|
|||||||
periods.find((value) => value === params.get("period")) ?? "month";
|
periods.find((value) => value === params.get("period")) ?? "month";
|
||||||
const filter =
|
const filter =
|
||||||
filters.find((value) => value === params.get("events")) ?? "all";
|
filters.find((value) => value === params.get("events")) ?? "all";
|
||||||
const scope = `${uid}:${filter}`;
|
|
||||||
const [pagination, setPagination] = useState<{
|
|
||||||
scope: string;
|
|
||||||
cursors: string[];
|
|
||||||
}>({ scope, cursors: [] });
|
|
||||||
const cursors = pagination.scope === scope ? pagination.cursors : [];
|
|
||||||
const client = useQueryClient();
|
|
||||||
const quote = useStockQuote(uid);
|
const quote = useStockQuote(uid);
|
||||||
const candles = useStockCandles(uid, period);
|
const candles = useStockCandles(uid, period);
|
||||||
const position = useStockPosition(uid);
|
const position = useStockPosition(uid);
|
||||||
const company = useStockCompany(uid);
|
const company = useStockCompany(uid);
|
||||||
const dividends = useStockDividends(uid);
|
const dividends = useStockDividends(uid);
|
||||||
const firstPage = {
|
const history = useInstrumentOperations(uid, filter, categories[filter]);
|
||||||
instrumentUid: uid,
|
const { operations } = history;
|
||||||
categories: categories[filter],
|
|
||||||
limit: 20,
|
|
||||||
};
|
|
||||||
const operations = useBrokerageOperations(uid ? "all" : undefined, {
|
|
||||||
...firstPage,
|
|
||||||
cursor: cursors.at(-1),
|
|
||||||
});
|
|
||||||
const sync = useScreenSync(
|
const sync = useScreenSync(
|
||||||
{
|
{
|
||||||
screen: "stock",
|
screen: "stock",
|
||||||
@@ -75,15 +58,6 @@ function useStockPageVm() {
|
|||||||
next.set(key, value);
|
next.set(key, value);
|
||||||
setParams(next, { replace: true });
|
setParams(next, { replace: true });
|
||||||
};
|
};
|
||||||
const retryOperations = () => {
|
|
||||||
if (apiErrorCode(operations.error) === "INVALID_OPERATIONS_CURSOR") {
|
|
||||||
setPagination({ scope, cursors: [] });
|
|
||||||
void client.invalidateQueries({
|
|
||||||
queryKey: brokerageOperationsQueryOptions("all", firstPage).queryKey,
|
|
||||||
exact: true,
|
|
||||||
});
|
|
||||||
} else void sync.retry();
|
|
||||||
};
|
|
||||||
const chartData =
|
const chartData =
|
||||||
candles.data?.candles.map((candle) => ({
|
candles.data?.candles.map((candle) => ({
|
||||||
...candle,
|
...candle,
|
||||||
@@ -145,7 +119,7 @@ function useStockPageVm() {
|
|||||||
operations: operations.data,
|
operations: operations.data,
|
||||||
operationsState: {
|
operationsState: {
|
||||||
...section(operations, sync.retry),
|
...section(operations, sync.retry),
|
||||||
retry: retryOperations,
|
retry: () => history.retry(sync.retry),
|
||||||
},
|
},
|
||||||
eventRows:
|
eventRows:
|
||||||
operations.data?.items.map((item) => ({
|
operations.data?.items.map((item) => ({
|
||||||
@@ -155,15 +129,9 @@ function useStockPageVm() {
|
|||||||
})) ?? [],
|
})) ?? [],
|
||||||
filter,
|
filter,
|
||||||
setFilter: (value: string) => setParam("events", value),
|
setFilter: (value: string) => setParam("events", value),
|
||||||
hasPrevious: cursors.length > 0,
|
hasPrevious: history.hasPrevious,
|
||||||
onPrevious: () => setPagination({ scope, cursors: cursors.slice(0, -1) }),
|
onPrevious: history.onPrevious,
|
||||||
onNext: () => {
|
onNext: history.onNext,
|
||||||
if (operations.data?.nextCursor)
|
|
||||||
setPagination({
|
|
||||||
scope,
|
|
||||||
cursors: [...cursors, operations.data.nextCursor],
|
|
||||||
});
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
type StockPageVm = ReturnType<typeof useStockPageVm>;
|
type StockPageVm = ReturnType<typeof useStockPageVm>;
|
||||||
|
|||||||
@@ -22,6 +22,12 @@ function unwrapResourceResult<T extends Resource, E>(
|
|||||||
result: UseQueryResult<T, E>,
|
result: UseQueryResult<T, E>,
|
||||||
) {
|
) {
|
||||||
const missing = result.isSuccess && result.data.data === null;
|
const missing = result.isSuccess && result.data.data === null;
|
||||||
|
const data: T["data"] | undefined = result.data?.data;
|
||||||
|
const meta: T["meta"] | undefined = result.data?.meta;
|
||||||
|
const status: UseQueryResult<T, E>["status"] = missing
|
||||||
|
? "pending"
|
||||||
|
: result.status;
|
||||||
|
// Project resource fields; refetch still returns the transport envelope.
|
||||||
return {
|
return {
|
||||||
...result,
|
...result,
|
||||||
transport: {
|
transport: {
|
||||||
@@ -29,18 +35,11 @@ function unwrapResourceResult<T extends Resource, E>(
|
|||||||
fetchStatus: result.fetchStatus,
|
fetchStatus: result.fetchStatus,
|
||||||
error: result.error,
|
error: result.error,
|
||||||
},
|
},
|
||||||
data: result.data?.data ?? undefined,
|
data: data ?? undefined,
|
||||||
meta: result.data?.meta,
|
meta,
|
||||||
isPending: result.isPending || missing,
|
isPending: result.isPending || missing,
|
||||||
isSuccess: result.isSuccess && !missing,
|
isSuccess: result.isSuccess && !missing,
|
||||||
status: missing ? "pending" : result.status,
|
status,
|
||||||
} as UseQueryResult<NonNullable<T["data"]>, E> & {
|
|
||||||
meta?: T["meta"];
|
|
||||||
transport: {
|
|
||||||
status: UseQueryResult<T, E>["status"];
|
|
||||||
fetchStatus: UseQueryResult<T, E>["fetchStatus"];
|
|
||||||
error: E | null;
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+45
-3
@@ -1,8 +1,12 @@
|
|||||||
import { fileURLToPath, URL } from "node:url";
|
import { fileURLToPath, URL } from "node:url";
|
||||||
import react from "@vitejs/plugin-react";
|
import react from "@vitejs/plugin-react";
|
||||||
|
import { playwright } from "@vitest/browser-playwright";
|
||||||
import { configDefaults, defineConfig } from "vitest/config";
|
import { configDefaults, defineConfig } from "vitest/config";
|
||||||
|
|
||||||
const CI_TEST_TIMEOUT_MS = 15_000;
|
const CI_TEST_TIMEOUT_MS = 15_000;
|
||||||
|
const LAYOUT_VIEWPORT_HEIGHT = 900;
|
||||||
|
const LAYOUT_NARROW_WIDTH = 375;
|
||||||
|
const LAYOUT_WIDE_WIDTH = 1280;
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
@@ -10,9 +14,47 @@ export default defineConfig({
|
|||||||
test: {
|
test: {
|
||||||
// CI runs the UI scenarios with coverage on a slower runner.
|
// CI runs the UI scenarios with coverage on a slower runner.
|
||||||
testTimeout: process.env.CI ? CI_TEST_TIMEOUT_MS : undefined,
|
testTimeout: process.env.CI ? CI_TEST_TIMEOUT_MS : undefined,
|
||||||
environment: "jsdom",
|
projects: [
|
||||||
exclude: configDefaults.exclude,
|
{
|
||||||
setupFiles: "./src/test/setup.ts",
|
extends: true,
|
||||||
|
test: {
|
||||||
|
name: "unit",
|
||||||
|
environment: "jsdom",
|
||||||
|
exclude: [...configDefaults.exclude, "**/*.browser.test.tsx"],
|
||||||
|
setupFiles: "./src/test/setup.ts",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
extends: true,
|
||||||
|
test: {
|
||||||
|
name: "browser",
|
||||||
|
include: ["src/**/*.browser.test.tsx"],
|
||||||
|
browser: {
|
||||||
|
enabled: true,
|
||||||
|
provider: playwright(),
|
||||||
|
headless: true,
|
||||||
|
instances: [
|
||||||
|
{
|
||||||
|
browser: "chromium",
|
||||||
|
name: "browser-narrow",
|
||||||
|
viewport: {
|
||||||
|
width: LAYOUT_NARROW_WIDTH,
|
||||||
|
height: LAYOUT_VIEWPORT_HEIGHT,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
browser: "chromium",
|
||||||
|
name: "browser-wide",
|
||||||
|
viewport: {
|
||||||
|
width: LAYOUT_WIDE_WIDTH,
|
||||||
|
height: LAYOUT_VIEWPORT_HEIGHT,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
coverage: {
|
coverage: {
|
||||||
include: ["src/**/*.{ts,tsx}", "packages/*/src/**/*.{ts,tsx}"],
|
include: ["src/**/*.{ts,tsx}", "packages/*/src/**/*.{ts,tsx}"],
|
||||||
exclude: ["src/test/**", "**/*.test.{ts,tsx}", "**/*.stories.tsx"],
|
exclude: ["src/test/**", "**/*.test.{ts,tsx}", "**/*.stories.tsx"],
|
||||||
|
|||||||
Reference in New Issue
Block a user