diff --git a/.gitea/workflows/image.yml b/.gitea/workflows/image.yml
index d802cea..5ad0de3 100644
--- a/.gitea/workflows/image.yml
+++ b/.gitea/workflows/image.yml
@@ -30,6 +30,8 @@ jobs:
- run: npm ci
env:
HUSKY: '0'
+ - name: Install Chromium and system dependencies
+ run: npx --no-install playwright install --with-deps chromium
- run: npm run verify
env:
KNIP_DISABLE_RAW_TRANSFER: '1'
diff --git a/.gitea/workflows/verify.yml b/.gitea/workflows/verify.yml
index bd7f96f..8664042 100644
--- a/.gitea/workflows/verify.yml
+++ b/.gitea/workflows/verify.yml
@@ -21,6 +21,8 @@ jobs:
- run: npm ci
env:
HUSKY: '0'
+ - name: Install Chromium and system dependencies
+ run: npx --no-install playwright install --with-deps chromium
- name: Run all frontend gates
run: npm run verify
env:
diff --git a/.gitignore b/.gitignore
index 17ca640..82a10c4 100644
--- a/.gitignore
+++ b/.gitignore
@@ -12,6 +12,7 @@ dist
storybook-static
dist-ssr
coverage
+.vitest
.scannerwork
*.local
diff --git a/README.md b/README.md
index 94f3292..0097ab0 100644
--- a/README.md
+++ b/README.md
@@ -75,12 +75,19 @@ Service worker кеширует только статическую оболоч
```bash
nvm use --silent
+npm ci
+npx --no-install playwright install chromium
npm run verify
```
Команда проверяет, что generated OpenAPI types соответствуют committed snapshot,
затем запускает strict TypeScript, Biome, один coverage-backed запуск Vitest,
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 и ежедневно
проверяет полную доступную историю Gitleaks 8.30.1 и выполняет
`npm audit --include=dev --audit-level=low`. Любой advisory блокирует security job;
diff --git a/package-lock.json b/package-lock.json
index 0ac4bc4..84a3bcd 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -49,6 +49,7 @@
"@types/react": "19.3.0",
"@types/react-dom": "19.3.0",
"@vitejs/plugin-react": "6.1.1",
+ "@vitest/browser-playwright": "5.0.0",
"@vitest/coverage-v8": "5.0.0",
"babel-plugin-react-compiler": "1.0.0",
"conventional-changelog-conventionalcommits": "10.4.0",
@@ -59,6 +60,7 @@
"lint-staged": "17.5.1",
"msw": "2.15.0",
"openapi-typescript": "7.13.0",
+ "playwright": "1.63.0",
"release-it": "21.0.2",
"storybook": "10.6.0",
"typescript": "5.9.3",
@@ -853,6 +855,13 @@
"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": {
"version": "2.4.2",
"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"
}
},
+ "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": {
"version": "8.11.2",
"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": {
"version": "5.0.0",
"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": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.0.tgz",
@@ -5466,6 +5543,42 @@
"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": {
"resolved": "packages/icons",
"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": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz",
@@ -7646,6 +7766,13 @@
"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": {
"version": "0.3.5",
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
@@ -9946,6 +10073,16 @@
"integrity": "sha512-7DnN7TmbLcYXcG4RVadXIihWlyuM9afoUww8Y5Agg431kGKiuL2/OMyP4mJ5wLz+pvN3t5ySClLOaVXJ+wekRQ==",
"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": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -10749,6 +10886,35 @@
"dev": true,
"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": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz",
@@ -10759,6 +10925,16 @@
"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": {
"version": "1.1.0",
"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"
}
},
+ "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": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz",
@@ -13643,6 +13834,16 @@
"dev": true,
"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": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz",
diff --git a/package.json b/package.json
index 275132b..ad387de 100644
--- a/package.json
+++ b/package.json
@@ -72,6 +72,7 @@
"@types/react": "19.3.0",
"@types/react-dom": "19.3.0",
"@vitejs/plugin-react": "6.1.1",
+ "@vitest/browser-playwright": "5.0.0",
"@vitest/coverage-v8": "5.0.0",
"babel-plugin-react-compiler": "1.0.0",
"conventional-changelog-conventionalcommits": "10.4.0",
@@ -82,6 +83,7 @@
"lint-staged": "17.5.1",
"msw": "2.15.0",
"openapi-typescript": "7.13.0",
+ "playwright": "1.63.0",
"release-it": "21.0.2",
"storybook": "10.6.0",
"typescript": "5.9.3",
diff --git a/src/entities/broker-operation/api/useBrokerageOperations.test.tsx b/src/entities/broker-operation/api/useBrokerageOperations.test.tsx
index 800ecf8..fea398b 100644
--- a/src/entities/broker-operation/api/useBrokerageOperations.test.tsx
+++ b/src/entities/broker-operation/api/useBrokerageOperations.test.tsx
@@ -8,7 +8,7 @@ import {
} from "./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({
accessToken: () => "token",
refresh: async () => true,
diff --git a/src/entities/portfolio/api/resource-lifecycle.test.tsx b/src/entities/portfolio/api/resource-lifecycle.test.tsx
index 6bf9d4f..16e8609 100644
--- a/src/entities/portfolio/api/resource-lifecycle.test.tsx
+++ b/src/entities/portfolio/api/resource-lifecycle.test.tsx
@@ -79,8 +79,49 @@ describe("persistent resource query lifecycle", () => {
await waitFor(() => expect(result.current.meta?.sync.state).toBe("failed"));
expect(result.current.data).toBeUndefined();
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");
});
+ 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 () => {
auth();
let release!: () => void;
diff --git a/src/entities/portfolio/api/usePortfolio.test.tsx b/src/entities/portfolio/api/usePortfolio.test.tsx
index efb6e4c..9c580aa 100644
--- a/src/entities/portfolio/api/usePortfolio.test.tsx
+++ b/src/entities/portfolio/api/usePortfolio.test.tsx
@@ -8,7 +8,7 @@ import { server } from "@/test/server";
import { usePortfolio } from "./usePortfolio";
describe("usePortfolio", () => {
- it("uses the account endpoint with a 30 second cache and no retry", async () => {
+ it("loads the account portfolio", async () => {
configureAuthClient({
accessToken: () => "token",
refresh: async () => true,
diff --git a/src/features/instrument-details/index.ts b/src/features/instrument-details/index.ts
index dfaf39b..a8df803 100644
--- a/src/features/instrument-details/index.ts
+++ b/src/features/instrument-details/index.ts
@@ -1,4 +1,5 @@
export { section } from "./model/section";
+export { useInstrumentOperations } from "./model/useInstrumentOperations";
export { InstrumentChart } from "./ui/InstrumentChart.view";
export { InstrumentEvents } from "./ui/InstrumentEvents.view";
export { InstrumentPosition } from "./ui/InstrumentPosition.view";
diff --git a/src/features/instrument-details/model/useInstrumentOperations.ts b/src/features/instrument-details/model/useInstrumentOperations.ts
new file mode 100644
index 0000000..2042dce
--- /dev/null
+++ b/src/features/instrument-details/model/useInstrumentOperations.ts
@@ -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 };
diff --git a/src/pages/account/ui/AccountPage/AccountPage.test.tsx b/src/pages/account/ui/AccountPage/AccountPage.test.tsx
index 22ede2a..c9b718c 100644
--- a/src/pages/account/ui/AccountPage/AccountPage.test.tsx
+++ b/src/pages/account/ui/AccountPage/AccountPage.test.tsx
@@ -660,26 +660,6 @@ describe("AccountPage states", () => {
).toBeInTheDocument();
});
- it("renders the bonds section as a single-column grid on any screen width", () => {
- renderPage(
- ,
- );
- 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", () => {
renderPage(
{
+ 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(
+
+ ({
+ key: position.displayName,
+ positions: [position],
+ })),
+ }}
+ />
+ ,
+ );
+ 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();
+ }
+});
diff --git a/src/pages/bond/ui/BondPage/BondPage.vm.ts b/src/pages/bond/ui/BondPage/BondPage.vm.ts
index 0177c4b..4c81fef 100644
--- a/src/pages/bond/ui/BondPage/BondPage.vm.ts
+++ b/src/pages/bond/ui/BondPage/BondPage.vm.ts
@@ -1,5 +1,3 @@
-import { useQueryClient } from "@tanstack/react-query";
-import { useState } from "react";
import { useParams, useSearchParams } from "react-router";
import {
type BondPeriod,
@@ -10,12 +8,11 @@ import {
useBondPosition,
useBondQuote,
} from "@/entities/bond";
+import type { OperationCategory } from "@/entities/broker-operation";
import {
- brokerageOperationsQueryOptions,
- type OperationCategory,
- useBrokerageOperations,
-} from "@/entities/broker-operation";
-import { section } from "@/features/instrument-details";
+ section,
+ useInstrumentOperations,
+} from "@/features/instrument-details";
import { useScreenSync } from "@/features/tbank-connection";
import { apiErrorCode } from "@/shared/api";
import { formatDate, instrumentLogoUrl } from "@/shared/lib";
@@ -38,27 +35,13 @@ function useBondPageVm() {
periods.find((value) => value === params.get("period")) ?? "month";
const filter =
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 metrics = useBondMetrics(uid);
const candles = useBondCandles(uid, period);
const position = useBondPosition(uid);
const coupons = useBondCoupons(uid);
- const firstPage = {
- instrumentUid: uid,
- categories: categories[filter],
- limit: 20,
- };
- const operations = useBrokerageOperations(uid ? "all" : undefined, {
- ...firstPage,
- cursor: cursors.at(-1),
- });
+ const history = useInstrumentOperations(uid, filter, categories[filter]);
+ const { operations } = history;
const sync = useScreenSync(
{
screen: "bond",
@@ -75,15 +58,6 @@ function useBondPageVm() {
next.set(key, value);
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 =
candles.data?.candles.map((candle) => ({
...candle,
@@ -138,7 +112,7 @@ function useBondPageVm() {
operations: operations.data,
operationsState: {
...section(operations, sync.retry),
- retry: retryOperations,
+ retry: () => history.retry(sync.retry),
},
eventRows:
operations.data?.items.map((item) => ({
@@ -148,15 +122,9 @@ function useBondPageVm() {
})) ?? [],
filter,
setFilter: (value: string) => setParam("events", value),
- hasPrevious: cursors.length > 0,
- onPrevious: () => setPagination({ scope, cursors: cursors.slice(0, -1) }),
- onNext: () => {
- if (operations.data?.nextCursor)
- setPagination({
- scope,
- cursors: [...cursors, operations.data.nextCursor],
- });
- },
+ hasPrevious: history.hasPrevious,
+ onPrevious: history.onPrevious,
+ onNext: history.onNext,
};
}
type BondPageVm = ReturnType;
diff --git a/src/pages/stock/ui/StockPage/StockPage.test.tsx b/src/pages/stock/ui/StockPage/StockPage.test.tsx
index fc251ce..56175a6 100644
--- a/src/pages/stock/ui/StockPage/StockPage.test.tsx
+++ b/src/pages/stock/ui/StockPage/StockPage.test.tsx
@@ -70,6 +70,7 @@ function setup(
noPosition?: boolean;
failCompany?: boolean;
failStock?: boolean;
+ cursorError?: string;
path?: string;
slug?: string;
ambiguousTicker?: boolean;
@@ -79,6 +80,8 @@ function setup(
const requests: URL[] = [];
const resolved = { ...stock, slug: options.slug ?? uid };
let failQuote = false;
+ let cursorError = options.cursorError;
+ let operationPayment = "200";
server.use(
http.get("*/api/v1/stocks/:uid", () =>
options.failStock
@@ -166,6 +169,8 @@ function setup(
const url = new URL(request.url);
requests.push(url);
const next = url.searchParams.has("cursor");
+ if (next && cursorError)
+ return HttpResponse.json({ code: cursorError }, { status: 400 });
return HttpResponse.json(
resource({
accountId: "all",
@@ -182,7 +187,7 @@ function setup(
category: next ? "trade" : "income",
description: "",
account: { id: "main", name: "Основной" },
- payment: money("200"),
+ payment: money(operationPayment),
quantity: next ? "2" : null,
price: next ? money("100") : null,
},
@@ -211,6 +216,10 @@ function setup(
);
return {
requests,
+ recoverOperations: () => {
+ cursorError = undefined;
+ operationPayment = "201";
+ },
failQuote: () => {
failQuote = true;
},
@@ -374,7 +383,7 @@ describe("stock page", () => {
expect(requests).toHaveLength(previousRequests);
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();
await screen.findByText("Зачисление дивидендов");
fireEvent.click(screen.getByRole("button", { name: "Далее" }));
@@ -399,6 +408,14 @@ describe("stock page", () => {
),
).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: "Год" }));
await waitFor(() =>
expect(
@@ -406,6 +423,34 @@ describe("stock page", () => {
).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 () => {
const scopes: unknown[] = [];
server.use(
diff --git a/src/pages/stock/ui/StockPage/StockPage.vm.ts b/src/pages/stock/ui/StockPage/StockPage.vm.ts
index f996827..bab72b7 100644
--- a/src/pages/stock/ui/StockPage/StockPage.vm.ts
+++ b/src/pages/stock/ui/StockPage/StockPage.vm.ts
@@ -1,11 +1,5 @@
-import { useQueryClient } from "@tanstack/react-query";
-import { useState } from "react";
import { useParams, useSearchParams } from "react-router";
-import {
- brokerageOperationsQueryOptions,
- type OperationCategory,
- useBrokerageOperations,
-} from "@/entities/broker-operation";
+import type { OperationCategory } from "@/entities/broker-operation";
import {
type StockPeriod,
useStock,
@@ -15,7 +9,10 @@ import {
useStockPosition,
useStockQuote,
} from "@/entities/stock";
-import { section } from "@/features/instrument-details";
+import {
+ section,
+ useInstrumentOperations,
+} from "@/features/instrument-details";
import { useScreenSync } from "@/features/tbank-connection";
import { apiErrorCode } from "@/shared/api";
import { formatDate, instrumentLogoUrl } from "@/shared/lib";
@@ -38,27 +35,13 @@ function useStockPageVm() {
periods.find((value) => value === params.get("period")) ?? "month";
const filter =
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 candles = useStockCandles(uid, period);
const position = useStockPosition(uid);
const company = useStockCompany(uid);
const dividends = useStockDividends(uid);
- const firstPage = {
- instrumentUid: uid,
- categories: categories[filter],
- limit: 20,
- };
- const operations = useBrokerageOperations(uid ? "all" : undefined, {
- ...firstPage,
- cursor: cursors.at(-1),
- });
+ const history = useInstrumentOperations(uid, filter, categories[filter]);
+ const { operations } = history;
const sync = useScreenSync(
{
screen: "stock",
@@ -75,15 +58,6 @@ function useStockPageVm() {
next.set(key, value);
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 =
candles.data?.candles.map((candle) => ({
...candle,
@@ -145,7 +119,7 @@ function useStockPageVm() {
operations: operations.data,
operationsState: {
...section(operations, sync.retry),
- retry: retryOperations,
+ retry: () => history.retry(sync.retry),
},
eventRows:
operations.data?.items.map((item) => ({
@@ -155,15 +129,9 @@ function useStockPageVm() {
})) ?? [],
filter,
setFilter: (value: string) => setParam("events", value),
- hasPrevious: cursors.length > 0,
- onPrevious: () => setPagination({ scope, cursors: cursors.slice(0, -1) }),
- onNext: () => {
- if (operations.data?.nextCursor)
- setPagination({
- scope,
- cursors: [...cursors, operations.data.nextCursor],
- });
- },
+ hasPrevious: history.hasPrevious,
+ onPrevious: history.onPrevious,
+ onNext: history.onNext,
};
}
type StockPageVm = ReturnType;
diff --git a/src/shared/api/resource-query.ts b/src/shared/api/resource-query.ts
index 1f124e4..f368ed7 100644
--- a/src/shared/api/resource-query.ts
+++ b/src/shared/api/resource-query.ts
@@ -22,6 +22,12 @@ function unwrapResourceResult(
result: UseQueryResult,
) {
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["status"] = missing
+ ? "pending"
+ : result.status;
+ // Project resource fields; refetch still returns the transport envelope.
return {
...result,
transport: {
@@ -29,18 +35,11 @@ function unwrapResourceResult(
fetchStatus: result.fetchStatus,
error: result.error,
},
- data: result.data?.data ?? undefined,
- meta: result.data?.meta,
+ data: data ?? undefined,
+ meta,
isPending: result.isPending || missing,
isSuccess: result.isSuccess && !missing,
- status: missing ? "pending" : result.status,
- } as UseQueryResult, E> & {
- meta?: T["meta"];
- transport: {
- status: UseQueryResult["status"];
- fetchStatus: UseQueryResult["fetchStatus"];
- error: E | null;
- };
+ status,
};
}
diff --git a/vitest.config.ts b/vitest.config.ts
index 3893652..fd3e03b 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -1,8 +1,12 @@
import { fileURLToPath, URL } from "node:url";
import react from "@vitejs/plugin-react";
+import { playwright } from "@vitest/browser-playwright";
import { configDefaults, defineConfig } from "vitest/config";
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({
plugins: [react()],
@@ -10,9 +14,47 @@ export default defineConfig({
test: {
// CI runs the UI scenarios with coverage on a slower runner.
testTimeout: process.env.CI ? CI_TEST_TIMEOUT_MS : undefined,
- environment: "jsdom",
- exclude: configDefaults.exclude,
- setupFiles: "./src/test/setup.ts",
+ projects: [
+ {
+ 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: {
include: ["src/**/*.{ts,tsx}", "packages/*/src/**/*.{ts,tsx}"],
exclude: ["src/test/**", "**/*.test.{ts,tsx}", "**/*.stories.tsx"],