Введение
Мне нужен был способ использовать Remix с Vite и Cloudflare Workers-Pages с минимальной настройкой.
Я видел и другие репозитории, такие как:
- Евангелие ,
- Гириш21 ,
- Химоришиге .
Но у них были некоторые ограничения:
Я не хотел выполнять предварительную сборку, так как не хотел отравлять репозитории дополнительными файлами конфигурации.
Cloudflare Workers/Pages имеет другую цель. Стало сложно нацеливаться на нее с помощью tsup, поскольку такие пакеты, как Postgres, вытягивали зависимости узлов, ломающиеся при импорте в Remix.
Мне также нужен был способ использования различных целей (Remix-Cloudflare, Node/Bun)
Тем не менее, я благодарен им, поскольку они проложили путь к тому, чтобы это стало возможным!
Обязательно прочитайте раздел «Подводные камни» внизу!
Подписывайтесь на меня в социальных сетях!
Я создаю автоматизированную платформу тестирования для публичного использования, чтобы выявлять эти 1% ошибок в производстве.
Я делюсь своим прогрессом по следующим темам:
Репозиторий GitHub
Полную версию реализации можно посмотреть здесь .
Шаг за шагом
Требования
- NodeJS
- ПНПМ
- Docker (необязательно — для примера локальной базы данных)
Хотя это проведет вас через новый монорепозиторий, вполне допустимо преобразовать в него уже существующий.
Также предполагается, что у вас есть некоторые знания о Mono Repo.
Примечание:
- «at root» относится к начальному пути вашего монорепозитория. Для этого проекта он будет за пределами каталогов
libs
иpackages
.
Установить ТурбоРепо
Turborepo работает поверх рабочих пространств вашего менеджера пакетов для управления скриптами и выходами вашего проекта (он даже может кэшировать ваш выход). Пока что это единственный инструмент для монорепозитория, помимо Rush (который я не пробовал и который мне не нравится), который способен работать.
NX не поддерживает Vite от Remix (на момент написания статьи — 28 августа 2024 г.).
pnpm dlx create-turbo@latest
1. Настройте рабочие пространства PNPM
Мы будем использовать возможности рабочего пространства PNPM для управления зависимостями.
В каталоге Monorepo создайте файл pnpm-workspace.yaml
.
Внутри него добавьте:
packages: - "apps/*" - "libs/*"
Это сообщит pnpm, что все репозитории будут находиться внутри apps
и libs
. Обратите внимание, что использование libs
или packages
(как вы могли видеть в другом месте) не имеет значения.
2. Создайте пустой package.json в корне проекта:
pnpm init
{ "name": "@repo/main", "version": "1.0.0", "scripts": {}, "keywords": [], "author": "", "license": "ISC" }
Обратите внимание на name:@repo/main
Это говорит нам, что это основная запись приложения. Вам не нужно следовать определенному соглашению или использовать префикс @
. Люди используют его, чтобы отличать его от локальных/удалённых пакетов или чтобы упростить группировку в организацию.
3. Создайте файл turbo.json
в корне проекта:
{ "$schema": "https://turbo.build/schema.json", "tasks": { "build": {}, "dev": { "cache": false, "persistent": true }, "start": { "dependsOn": ["^build"], "persistent": true }, "preview": { "cache": false, "persistent": true }, "db:migrate": {} } }
Файл turbo.json сообщает репозиторию turbo, как интерпретировать наши команды. Все, что находится внутри ключа tasks
будет соответствовать тому, что находится в файле all package.json.
Обратите внимание, что мы определяем четыре команды. Они соответствуют командам в разделе скриптов package.json каждого репозитория. Однако не все package.json должны реализовывать эти команды.
Например: команда dev
будет вызвана turbo dev
, и она выполнит все пакеты, которые dev
найдены в package.json. Если вы не включите его в turbo, он не выполнится.
4. Создайте папку apps
в корне проекта.
mkdir apps
5. Создайте приложение Remix в папке apps
(или переместите существующее)
npx create-remix --template edmundhung/remix-worker-template
Когда вас попросят Install any dependencies with npm
ответьте «нет».
6. Переименуйте name
package.json в @repo/my-remix-cloudflare-app
(или свое имя)
{ - "name": "my-remix-cloudflare-app", + "name": "@repo/my-remix-cloudflare-app", "version": "1.0.0", "keywords": [], "author": "", "license": "ISC" }
7. Скопируйте зависимости и devDependencies из apps/<app>/package.json
в package.json
корневого каталога.
Например:
<корень>/package.json
{ "name": "@repo/main", "version": "1.0.0", "scripts": {}, "keywords": [], "author": "", "license": "ISC", "dependencies": { "@markdoc/markdoc": "^0.4.0", "@remix-run/cloudflare": "^2.8.1", "@remix-run/cloudflare-pages": "^2.8.1", "@remix-run/react": "^2.8.1", "isbot": "^3.6.5", "react": "^18.2.0", "react-dom": "^18.2.0" }, "devDependencies": { "@cloudflare/workers-types": "^4.20240222.0", "@octokit/types": "^12.6.0", "@playwright/test": "^1.42.1", "@remix-run/dev": "^2.8.1", "@remix-run/eslint-config": "^2.8.1", "@tailwindcss/typography": "^0.5.10", "@types/react": "^18.2.64", "@types/react-dom": "^18.2.21", "autoprefixer": "^10.4.18", "concurrently": "^8.2.2", "cross-env": "^7.0.3", "eslint": "^8.57.0", "eslint-config-prettier": "^9.1.0", "husky": "^9.0.11", "lint-staged": "^15.2.2", "msw": "^2.2.3", "postcss": "^8.4.35", "prettier": "^3.2.5", "prettier-plugin-tailwindcss": "^0.5.12", "rimraf": "^5.0.5", "tailwindcss": "^3.4.1", "typescript": "^5.4.2", "vite": "^5.1.5", "vite-tsconfig-paths": "^4.3.1", "wrangler": "^3.32.0" } }
8. Добавьте Turbo как devDependency на корневом уровне (это установит все пакеты Remix).
Проверьте, что turbo находится внутри package.json's devDependencies. Если его нет в списке, выполните следующую команду:
pnpm add turbo -D -w
Флаг -w
сообщает pnpm о необходимости установки в корне рабочей области.
9. Добавьте следующие записи в корневой файл package.json
Добавьте команду
dev
вscripts
Добавьте
packageManager
к опции
{ "name": "@repo/main", "version": "1.0.0", "scripts": { "dev": "turbo dev" }, "keywords": [], "author": "", "license": "ISC", "packageManager": "[email protected]", "dependencies": { // omitted for brevity }, "devDependencies": { // omitted for brevity } }
10. Убедитесь, что все работает, запустив pnpm dev
pnpm dev
11. Создайте папку Libs в корне проекта. Добавьте config, db и utils:
mkdir -p libs/config libs/db libs/utils
12. Добавьте src/index.ts
для каждого пакета.
touch libs/config/src/index.ts libs/db/src/index.ts libs/utils/src/index.ts
- Файл index.ts будет использоваться для экспорта всех пакетов.
- Мы будем использовать папку в качестве точки входа, чтобы сделать все компактным.
- Это соглашение, и вы можете ему не следовать.
13. Создайте пустой package.json и добавьте следующее в файл libs/config/package.json
:
{ "name": "@repo/config", "version": "1.0.0", "type": "module", "exports": { ".": { "import": "./src/index.ts", "default": "./src/index.ts" } } }
14. Сделайте то же самое для libs/db/package.json
:
{ "name": "@repo/db", "version": "1.0.0", "exports": { ".": { "import": "./src/index.ts", "default": "./src/index.ts" } } }
15. И libs/utils/package.json
:
{ "name": "@repo/utils", "version": "1.0.0", "exports": { ".": { "import": "./src/index.ts", "default": "./src/index.ts" } } }
- Указываем поле «exports». Это сообщает другим репозиториям, откуда импортировать пакет.
- Указываем поле «имя». Оно используется для установки пакета и ссылки на другие репозитории.
16. (DB) - Добавить Drizzle и Postgres
Примечания:
Я начинаю презирать ORM. Я потратил более 10 лет на изучение 6 разных, и эти знания невозможно передать.
У вас проблемы, когда выходят новые технологии. Prisma не поддерживает Cloudflare worker'ов из коробки.
Благодаря LLM писать сложные SQL-запросы стало проще, чем когда-либо.
Изучение SQL — универсального языка, и вряд ли что-то изменится.
pnpm add drizzle-orm drizle-kit --filter=@repo/db
Установите Postgres на уровне рабочей области. Смотрите раздел Подводные камни .
pnma add postgres -w
Примечания:
- Флаг
--filter=@repo/db
сообщает pnpm о необходимости добавить пакет в репозиторий db.
17. Добавьте dotenv в репозиторий рабочей области.
pnpm add dotenv -w
Примечания
- Флаг
-w
сообщает pnpm, что нужно установить его в корневой package.json
18. Добавьте проект конфигурации во все проекты.
pnpm add @repo/config -r --filter=!@repo/config
Примечания :
- Флаг
-r
сообщает pnpm о необходимости добавить пакет во все репозитории. - Флаг
--filter=!
сообщает pnpm о необходимости исключить репозиторий конфигурации. - Обратите внимание на
!
перед именем пакета.
19. (Необязательно) Не работают команды выше? Используйте .npmrc
Если pnpm извлекает пакеты из репозитория, мы можем создать файл .npmrc
в корне проекта.
.npmrc
link-workspace-packages= true prefer-workspace-packages=true
- Это сообщит pnpm о необходимости сначала использовать пакеты рабочего пространства.
- Спасибо ZoWnx из Reddit, который помог мне создать файл .nprmc
20. Настройте общий tsconfig.json
внутри Libs/Config
Используя возможности рабочих пространств pnpm, вы можете создавать файлы конфигурации, которые можно совместно использовать в разных проектах.
Мы создадим базовый файл tsconfig.lib.json, который будем использовать для наших библиотек.
Внутри libs/config
создайте экземпляр tsconfig.lib.json
:
touch "libs/config/tsconfig.base.lib.json"
Затем добавьте следующее:
tsconfig.base.lib.json
{ "$schema": "https://json.schemastore.org/tsconfig", "compilerOptions": { "lib": ["ES2022"], "module": "ESNext", "moduleResolution": "Bundler", "resolveJsonModule": true, "target": "ES2022", "strict": true, "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, "allowImportingTsExtensions": true, "allowJs": true, "noUncheckedIndexedAccess": true, "noEmit": true, "incremental": true, "composite": false, "declaration": true, "declarationMap": true, "inlineSources": false, "isolatedModules": true, "noUnusedLocals": false, "noUnusedParameters": false, "preserveWatchOutput": true, "experimentalDecorators": true, "emitDecoratorMetadata": true, "sourceMap": true, } }
21. Добавьте подключение к базе данных в репозиторий баз данных.
// libs/db/drizzle.config.ts (Yes, this one is at root of the db package, outside the src folder) // We don't want to export this file as this is ran at setup. import "dotenv/config"; // make sure to install dotenv package import { defineConfig } from "drizzle-kit"; export default defineConfig({ dialect: "postgresql", out: "./src/generated", schema: "./src/drizzle/schema.ts", dbCredentials: { url: process.env.DATABASE_URL!, }, // Print all statements verbose: true, // Always ask for confirmation strict: true, });
Файл схемы:
// libs/db/src/drizzle/schema.ts export const User = pgTable("User", { userId: char("userId", { length: 26 }).primaryKey().notNull(), subId: char("subId", { length: 36 }).notNull(), // We are not making this unique to support merging accounts in later // iterations email: text("email"), loginProvider: loginProviderEnum("loginProvider").array().notNull(), createdAt: timestamp("createdAt", { precision: 3, mode: "date" }).notNull(), updatedAt: timestamp("updatedAt", { precision: 3, mode: "date" }).notNull(), });
Файл клиента:
// libs/db/src/drizzle-client.ts import { drizzle, PostgresJsDatabase } from "drizzle-orm/postgres-js"; import postgres from "postgres"; import * as schema from "./schema"; export type DrizzleClient = PostgresJsDatabase<typeof schema>; let drizzleClient: DrizzleClient | undefined; type GetClientInput = { databaseUrl: string; env: string; mode?: "cloudflare" | "node"; }; declare var window: typeof globalThis; declare var self: typeof globalThis; export function getDrizzleClient(input: GetClientInput) { const { mode, env } = input; if (mode === "cloudflare") { return generateClient(input); } const globalObject = typeof globalThis !== "undefined" ? globalThis : typeof global !== "undefined" ? global : typeof window !== "undefined" ? window : self; if (env === "production") { drizzleClient = generateClient(input); } else if (globalObject) { if (!(globalObject as any).__db__) { (globalObject as any).__db__ = generateClient(input); } drizzleClient = (globalObject as any).__db__; } else { drizzleClient = generateClient(input); } return drizzleClient; } type GenerateClientInput = { databaseUrl: string; env: string; }; function generateClient(input: GenerateClientInput) { const { databaseUrl, env } = input; const isLoggingEnabled = env === "development"; // prepare: false for serverless try { const client = postgres(databaseUrl, { prepare: false }); const db = drizzle(client, { schema, logger: isLoggingEnabled }); return db; } catch (e) { console.log("ERROR", e); return undefined!; } }
Файл миграции:
// libs/db/src/drizzle/migrate.ts import { config } from "dotenv"; import { migrate } from "drizzle-orm/postgres-js/migrator"; import postgres from "postgres"; import { drizzle } from "drizzle-orm/postgres-js"; import "dotenv/config"; import path from "path"; config({ path: "../../../../apps/my-remix-cloudflare-app/.dev.vars" }); const ssl = process.env.ENVIRONMENT === "development" ? undefined : "require"; const databaseUrl = drizzle( postgres(`${process.env.DATABASE_URL}`, { ssl, max: 1 }) ); // Somehow the current starting path is /libs/db // Remember to have the DB running before running this script const migration = path.resolve("./src/generated"); const main = async () => { try { await migrate(databaseUrl, { migrationsFolder: migration, }); console.log("Migration complete"); } catch (error) { console.log(error); } process.exit(0); }; main();
Это должно быть выполнено после миграций.
И экспортировать клиент и схему в файл src/index.ts
. Другие запускаются в определенное время.
// libs/db/src/index.ts export * from "./drizzle/drizzle-client"; export * from "./drizzle/schema "
В вашем package.json
добавьте drizzle-kit generate
и код для запуска команды миграции:
{ "name": "@repo/db", "version": "1.0.0", "main": "./src/index.ts", "module": "./src/index.ts", "types": "./src/index.ts", "scripts": { "db:generate": "drizzle-kit generate", "db:migrate": "dotenv tsx ./drizzle/migrate", }, "exports": { ".": { "import": "./src/index.ts", "default": "./src/index.ts" } }, "dependencies": { "@repo/configs": "workspace:^", "drizzle-kit": "^0.24.1", "drizzle-orm": "^0.33.0", }, "devDependencies": { "@types/node": "^22.5.0" } }
22. Используйте общий tsconfig.json
для libs/db
и libs/utils
Создайте tsconfig.json для libs/db
и libs/utils
touch "libs/db/tsconfig.json" "libs/utils/tsconfig.json"
Затем добавьте к каждому:
{ "extends": "@repo/configs/tsconfig.base.lib.json", "include": ["./src"], }
- Обратите внимание, что
@repo/configs
используется в качестве пути для ссылки на наш tsconfig.base.lib.json. - Это очищает наш путь.
23. Установить TSX
TypeScript Execute (TSX) — это библиотека, альтернативная ts-node. Мы будем использовать ее для выполнения миграций drizzle.
pnpm add tsx -D --filter=@repo/db
24. Добавьте пустой .env в каталог libs/db
touch "libs/db/.env"
Добавьте следующее содержимое:
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/postgres" NODE_ENV="development" MODE="node"
25. Добавьте репозиторий libs/db
в наш проект ремикса
Из корня проекта запустите:
pnpm add @repo/db --filter=@repo/my-remix-cloudflare-app
Если это не сработает, перейдите в package.json apps/my-remix-cloudflare-app
и добавьте зависимость вручную.
{ "name": "@repo/my-remix-cloudflare-app", "version": "1.0.0", "dependencies": { "@repo/db": "workspace:*" } }
Обратите внимание workspace:*
в поле версии. Это говорит pnpm использовать любую версию пакета в workspace.
Если вы установили его через CLI с помощью pnpm add,
вы, вероятно, увидите что-то вроде workspace:^
. Это не должно иметь значения, если вы не увеличите версии локальных пакетов.
Если вы добавили это вручную, то запустите pnpm install
из корня проекта.
Мы должны иметь возможность использовать @repo/db в нашем проекте.
26. Добавьте немного общего кода в наши утилиты:
Добавьте этот код в файл libs/utils/src/index.ts
:
// libs/utils/src/index.ts export function hellowWorld() { return "Hello World!"; }
27. Установите библиотеки/утилиты в наше приложение Remix:
pnpm add @repo/db --filter=@repo/my-remix-cloudflare-app
28. (Необязательно) Запуск Postgres из контейнера Docker
Если у вас нет запущенного экземпляра Postgres, мы можем запустить его с помощью docker-compose. Обратите внимание, я предполагаю, что вы знаете Docker.
Создайте файл docker-compose.yml
в корне проекта.
# Auto-generated docker-compose.yml file. version: '3.8' # Define services. services: postgres: image: postgres:latest restart: always environment: - POSTGRES_USER=postgres - POSTGRES_PASSWORD=postgres - POSTGRES_DB=postgres ports: - "5432:5432" volumes: - ./postgres-data:/var/lib/postgresql/data pgadmin: # To connect PG Admin, navigate to http://localhost:8500 use: # host.docker.internal # postgres # (username) postgres # (password) postgres image: dpage/pgadmin4 ports: - "8500:80" environment: PGADMIN_DEFAULT_EMAIL: [email protected] PGADMIN_DEFAULT_PASSWORD: admin
Затем вы можете запустить:
docker-compose up -d
Флаг -d
указывает docker-compose работать в отсоединенном режиме, чтобы вы снова могли получить доступ к своему терминалу.
29. Сгенерируйте схему БД
Теперь перейдите в репозиторий libs/db и запустите db:generate
.
cd `./libs/db` && pnpm db:generate
- Обратите внимание, что
db:generate
— это псевдоним для:drizzle-kit generate
- Убедитесь, что у вас есть правильный .env.
- Кроме того, предполагается, что у вас запущен экземпляр Postgres.
30. Запустите миграции.
Нам необходимо запустить миграции, чтобы сформировать каркас всех таблиц в нашей базе данных.
Перейдите в репозиторий libs/db (если вы там еще не находитесь) и запустите db:generate
.
cd `./libs/db` && pnpm db:migrate
- Обратите внимание, что
db:migrate
— это псевдоним для:dotenv tsx ./drizzle/migrate
- Убедитесь, что у вас есть правильный .env.
- Кроме того, предполагается, что у вас запущен экземпляр Postgres.
31. Вставьте вызов базы данных в свое приложение Remix.
// apps/my-remix-cloudflare-app/app/routes/_index.tsx import type { LoaderFunctionArgs } from '@remix-run/cloudflare'; import { json, useLoaderData } from '@remix-run/react'; import { getDrizzleClient } from '@repo/db'; import { Markdown } from '~/components'; import { getFileContentWithCache } from '~/services/github.server'; import { parse } from '~/services/markdoc.server'; export async function loader({ context }: LoaderFunctionArgs) { const client = await getDrizzleClient({ databaseUrl: context.env.DATABASE_URL, env: 'development', mode: 'cloudflare', }); if (client) { const res = await client.query.User.findFirst(); console.log('res', res); } const content = await getFileContentWithCache(context, 'README.md'); return json( { content: parse(content), // user: firstUser, }, { headers: { 'Cache-Control': 'public, max-age=3600', }, }, ); } export default function Index() { const { content } = useLoaderData<typeof loader>(); return <Markdown content={content} />; }
- Обратите внимание, что здесь мы не следуем передовым практикам.
- Я бы посоветовал вам не делать никаких вызовов БД напрямую внутри загрузчика, а создать абстракцию, которая будет их вызывать.
- Cloudflare сложен в настройке переменных окружения. Они передаются по запросу
32. Добавьте в ваш .dev.vars следующее:
apps/my-remix-cloudflare-app/.dev.vars
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/postgres"
33. Реализуйте проект ремикса!
Запустить экземпляр postgres (если не готов)
docker-compose up -d
Запустить проект
pnpm turbo dev
Расширенный вариант использования — CQRS в GetLoadContext в Cloudflare Workers.
В своих проектах я, как правило, реализую шаблон CQRS , 2. Это выходит за рамки данного руководства.
Тем не менее, в контексте загрузки я стараюсь внедрять посредника (и флэш-сообщение cookie), который отделит все мое приложение Remix от моей бизнес-логики.
Это выглядит примерно так:
export const getLoadContext: GetLoadContext = async ({ context, request }) => { const isEnvEmpty = Object.keys(context.cloudflare.env).length === 0; const env = isEnvEmpty ? process.env : context.cloudflare.env; const sessionFlashSecret = env.SESSION_FLASH_SECRET; const flashStorage = createCookieSessionStorage({ cookie: { name: "__flash", httpOnly: true, maxAge: 60, path: "/", sameSite: "lax", secrets: [sessionFlashSecret], secure: true, }, }); return { ...context, cloudflare: { ...context.cloudflare, env, }, dispatch: (await dispatchWithContext({ env: env as unknown as Record<string, string>, request, })) as Dispatch, flashStorage, }; };
Обратите внимание, что код отправки опущен. Вы можете узнать больше об этом в моей статье о том, как в 10 раз улучшить свой опыт разработки TypeScript здесь .
Я могу удалить Remix или использовать другой потребитель, не изменяя свой код.
Но….
При работе в структуре монорепозитория с использованием турборепозитория возникают дополнительные трудности.
Если вы импортируете файл TypeScript из пакета в контексте загрузки, скажем @repo/db
Vite вернет ошибку, что файл с расширением .ts
неизвестен, и не будет знать, как его обработать.
Это происходит из-за того, что load-context + workspaces находятся за пределами основного графика импорта сайта, в результате чего файлы TypeScript остаются вне воспроизведения.
Хитрость заключается в том, чтобы использовать tsx
и загрузить его перед вызовом Vite, что сработает. Это важно, поскольку это преодолевает следующие ограничения:
Зависимости пакетов Cloudflare.
Зависимости пакетов Cloudflare и предварительная сборка
Прежде всего, это был тот шаг, которого я пытался избежать, поскольку это означало, что мне пришлось бы ввести шаг сборки для каждого из пакетов, что означало бы дополнительную настройку.
К счастью, это не сработало для Cloudflare Pages. Определенные библиотеки, такие как Postgres, обнаружат среду выполнения и вытащат требуемый пакет.
Есть обходной путь: мы можем использовать tsx для загрузки всех файлов TypeScript и транспилировать их перед выполнением.
Вы можете утверждать, что это этап предварительной сборки, но поскольку он все еще находится на уровне репозитория ремикса, я не вижу существенных проблем с этим подходом.
Чтобы решить эту проблему, мы добавляем tsx как зависимость:
pnpm add tsx -D --filter=@repo/my-remix-cloudflare-app
Затем нам нужно изменить наш package.json
и добавить процесс tsx в каждый из наших скриптов ремикса:
{ "name": "@repo/my-remix-cloudflare-app", "version": "1.0.0", "scripts": { // Other scripts omitted "build": "NODE_OPTIONS=\"--import tsx/esm\" remix vite:build", "dev": "NODE_OPTIONS=\"--import tsx/esm\" remix vite:dev", "start": "NODE_OPTIONS=\"--import tsx/esm\" wrangler pages dev ./build/client" } }
Дополнительно
Создание файла .npmrc
Если у вас возникли проблемы при добавлении локальных пакетов с помощью командной строки, вы можете создать файл .npmrc
в корне проекта.
.npmrc
link-workspace-packages= true prefer-workspace-packages=true
Это сообщит pnpm о необходимости сначала использовать пакеты рабочего пространства.
Спасибо ZoWnx из Reddit, который помог мне создать файл .nprmc
Подводные камни -
Будьте осторожны с именами
.client
и.server
в ваших файлах. Даже если это отдельная библиотека. Remix использует их, чтобы определить, является ли это файлом клиента или сервера. Проект не компилируется для каждого репозитория, поэтому он выдаст ошибку импорта!
Если у вас возникли проблемы с многоплатформенными пакетами, такими как Postgres, лучше установить его на уровне рабочей области. Он обнаружит правильный импорт. Установка его напрямую в репозиторий @repo/db сломает его при импорте в Remix.
Вот и всё, ребята!!!
Репозиторий GitHub
Полную версию реализации можно посмотреть здесь .
Подписывайтесь на меня в социальных сетях!
Я создаю автоматизированного инженера по тестированию для общественности, чтобы выявлять эти 1% ошибок в производстве.
Я делюсь своим прогрессом по следующим темам: