diff --git a/.gitignore b/.gitignore index fc596658..2bb30334 100644 --- a/.gitignore +++ b/.gitignore @@ -122,6 +122,11 @@ coverage/ .nyc_output/ junit.xml +# Playwright +playwright-report/ +test-results/ +blob-report/ + *storybook.log # Claude Code 파일 기반 텍스트 교환 스크래치 (file-exchange skill) diff --git a/.mcp.json b/.mcp.json index cd47a5bf..68c5f224 100644 --- a/.mcp.json +++ b/.mcp.json @@ -12,13 +12,14 @@ "type": "http", "url": "https://mcp.sentry.dev/mcp" }, - "awslabs.aws-api-mcp-server": { + "aws-mcp": { "command": "uvx", - "args": ["awslabs.aws-api-mcp-server@latest"], - "env": { - "AWS_REGION": "ap-northeast-2", - "FASTMCP_LOG_LEVEL": "ERROR" - } + "args": [ + "mcp-proxy-for-aws@1.6.4", + "https://aws-mcp.us-east-1.api.aws/mcp", + "--profile", "claude-aws-mcp", + "--metadata", "AWS_REGION=ap-northeast-2" + ] }, "kakao-playmcp": { "type": "http", diff --git a/apps/backend/package.json b/apps/backend/package.json index b23324b8..1a54893e 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -22,7 +22,9 @@ "test": "vitest run", "test:watch": "vitest", "test:integration": "yarn db:migrate:test && dotenv -e ./.env.test -- vitest run -c vitest.integration.config.ts", - "test:e2e": "yarn db:migrate:test && dotenv -e ./.env.test -- vitest run -c vitest.e2e.config.ts" + "test:e2e": "yarn db:migrate:test && dotenv -e ./.env.test -- vitest run -c vitest.e2e.config.ts", + "start:test": "dotenv -e ./.env.test -- nest start", + "seed:e2e-fixture": "dotenv -e ./.env.test -- ts-node -r tsconfig-paths/register -O '{\"module\":\"CommonJS\"}' src/test/e2e/fixture-seed.script.ts" }, "dependencies": { "@aws-sdk/client-s3": "^3.922.0", diff --git a/apps/backend/src/modules/notification/utils/user-order-alimtalk.util.ts b/apps/backend/src/modules/notification/utils/user-order-alimtalk.util.ts index 65affadd..13e73506 100644 --- a/apps/backend/src/modules/notification/utils/user-order-alimtalk.util.ts +++ b/apps/backend/src/modules/notification/utils/user-order-alimtalk.util.ts @@ -54,7 +54,8 @@ function formatDateTime(date: Date | null): string { day: "numeric", hour: "2-digit", minute: "2-digit", - hour12: false, + // hour12: false만 쓰면 일부 ICU가 자정대(00:00~00:59)를 "24:00~24:59"로 표시한다. + hourCycle: "h23", }).format(date); } diff --git a/apps/backend/src/modules/store/utils/store-business-calendar.util.ts b/apps/backend/src/modules/store/utils/store-business-calendar.util.ts index 926cd670..40564cf7 100644 --- a/apps/backend/src/modules/store/utils/store-business-calendar.util.ts +++ b/apps/backend/src/modules/store/utils/store-business-calendar.util.ts @@ -41,7 +41,9 @@ export function getSeoulWallClockForPickup(pickupUtc: Date): { day: "2-digit", hour: "2-digit", minute: "2-digit", - hour12: false, + // hour12: false만 쓰면 일부 ICU가 자정대(00:00~00:59)를 "24:00~24:59"로 포맷해 + // minuteOfDay가 1440대로 계산되는 문제가 있어(getSeoulHour와 동일 사유), hourCycle로 직접 고정한다. + hourCycle: "h23", weekday: "short", }).formatToParts(pickupUtc); diff --git a/apps/backend/src/test/e2e/fixture-seed.script.ts b/apps/backend/src/test/e2e/fixture-seed.script.ts new file mode 100644 index 00000000..617138e8 --- /dev/null +++ b/apps/backend/src/test/e2e/fixture-seed.script.ts @@ -0,0 +1,155 @@ +import { PrismaClient } from "@apps/backend/infra/database/prisma/generated/client"; +import { assertTestDatabase } from "@apps/backend/test/integration/db"; + +/** + * web-user Playwright E2E(`apps/web-user/e2e`)가 의존하는 최소 픽스처(판매자/스토어/상품)를 + * 테스트 DB에 find-or-create로 준비한다. 매번 새로 만들지 않고 마커 필드로 재사용하므로 + * 반복 실행해도 데이터가 쌓이지 않는다. + * + * web-user 쪽 테스트는 `GET /v1/consumer/products?search=...`로 아래 상품을 이름 검색해서 + * productId를 찾으므로, 이 스크립트와 web-user 테스트 사이에 별도 파일 공유가 필요 없다. + */ + +const prisma = new PrismaClient(); + +const FIXTURE = { + // 실제 휴대폰 번호 형식이 아니라서 실사용자 계정과 겹치지 않음 (REVIEW_LOGIN_ACCOUNT_PHONE_MARKER와 동일 관례) + SELLER_PHONE: "E2E_FIXTURE_SELLER", + STORE: { + // 스토어 이름은 한글/영문/숫자/공백만 허용(validators.decorator.ts)이라 대괄호 마커를 못 씀 + NAME: "E2E 테스트 스토어", + BUSINESS_NO: "000-00-E2E01", + PERMISSION_MANAGEMENT_NUMBER: "제E2E-0001호", + ADDRESS: "서울특별시 강남구 역삼동 123-45", + ROAD_ADDRESS: "서울특별시 강남구 테헤란로 1", + DETAIL_ADDRESS: "101호", + ZONECODE: "06234", + LATITUDE: 37.5, + LONGITUDE: 127.03, + }, + PRODUCT: { + // web-user e2e가 이 마커로 검색해서 productId를 찾는다. + NAME: "[E2E] 테스트 케이크", + PRICE: 30000, + SIZE_OPTIONS: [ + { + id: "e2e-size-mini", + visible: "ENABLE", + displayName: "미니", + lengthCm: 10, + price: 0, + description: "1~2인용", + }, + ], + FLAVOR_OPTIONS: [ + { + id: "e2e-flavor-choco", + visible: "ENABLE", + displayName: "초콜릿", + price: 0, + }, + ], + // 0이면 레터링 textarea의 maxLength가 0이 되어 아예 입력이 안 되므로 반드시 0보다 커야 함 + LETTERING_MAX_LENGTH: 20, + }, +}; + +async function upsertSeller() { + const existing = await prisma.seller.findUnique({ where: { phone: FIXTURE.SELLER_PHONE } }); + if (existing) return existing; + + return prisma.seller.create({ + data: { + phone: FIXTURE.SELLER_PHONE, + name: "E2E 테스트 판매자", + isPhoneVerified: true, + isActive: true, + }, + }); +} + +async function upsertStore(sellerId: string) { + const existing = await prisma.store.findFirst({ + where: { sellerId, businessNo: FIXTURE.STORE.BUSINESS_NO }, + }); + if (existing) return existing; + + return prisma.store.create({ + data: { + sellerId, + name: FIXTURE.STORE.NAME, + businessNo: FIXTURE.STORE.BUSINESS_NO, + representativeName: "E2E 대표자", + openingDate: "20200101", + businessName: "E2E 테스트 사업자", + businessSector: "제조업", + businessType: "베이커리", + permissionManagementNumber: FIXTURE.STORE.PERMISSION_MANAGEMENT_NUMBER, + // 주문 생성 DTO가 pickupAddress 등을 필수로 요구하므로 채워야 함 + address: FIXTURE.STORE.ADDRESS, + roadAddress: FIXTURE.STORE.ROAD_ADDRESS, + detailAddress: FIXTURE.STORE.DETAIL_ADDRESS, + zonecode: FIXTURE.STORE.ZONECODE, + latitude: FIXTURE.STORE.LATITUDE, + longitude: FIXTURE.STORE.LONGITUDE, + // standardOpenTime === standardCloseTime === "00:00" => 하루 전체 영업 (스키마 기본값) + }, + }); +} + +async function upsertProduct(storeId: string) { + const existing = await prisma.product.findFirst({ + where: { storeId, name: FIXTURE.PRODUCT.NAME }, + }); + if (existing) return existing; + + return prisma.product.create({ + data: { + storeId, + name: FIXTURE.PRODUCT.NAME, + originalPrice: FIXTURE.PRODUCT.PRICE, + salePrice: FIXTURE.PRODUCT.PRICE, + salesStatus: "ENABLE", + visibilityStatus: "ENABLE", + cakeSizeOptions: FIXTURE.PRODUCT.SIZE_OPTIONS, + cakeFlavorOptions: FIXTURE.PRODUCT.FLAVOR_OPTIONS, + letteringVisible: "ENABLE", + letteringMaxLength: FIXTURE.PRODUCT.LETTERING_MAX_LENGTH, + imageUploadEnabled: "DISABLE", + productNumber: `E2E-${Date.now()}`, + productNoticeFoodType: "빵류", + productNoticeProducer: "E2E 테스트 제조사", + productNoticeOrigin: "국내산", + productNoticeAddress: "서울특별시", + productNoticeManufactureDate: "제조일에 별도 표기", + productNoticeExpirationDate: "제조일로부터 3일", + productNoticePackageCapacity: "1개", + productNoticePackageQuantity: "1개", + productNoticeIngredients: "밀가루 외", + productNoticeCalories: "100kcal", + productNoticeSafetyNotice: "-", + productNoticeGmoNotice: "해당없음", + productNoticeImportNotice: "해당없음", + productNoticeCustomerService: "1544-0000", + }, + }); +} + +async function main() { + assertTestDatabase(); + + const seller = await upsertSeller(); + const store = await upsertStore(seller.id); + const product = await upsertProduct(store.id); + + console.log(`[e2e-fixture] seller=${seller.id} store=${store.id} product=${product.id}`); +} + +main() + .catch((error) => { + console.error("[e2e-fixture] 시딩 실패:", error); + process.exitCode = 1; + }) + .finally(async () => { + await prisma.$disconnect(); + }); diff --git a/apps/web-user/e2e/helpers/review-login.ts b/apps/web-user/e2e/helpers/review-login.ts new file mode 100644 index 00000000..69b53591 --- /dev/null +++ b/apps/web-user/e2e/helpers/review-login.ts @@ -0,0 +1,25 @@ +import { expect, type Page } from "@playwright/test"; + +/** apps/backend/src/modules/auth/constants/auth.constants.ts의 REVIEW_LOGIN_CODE와 동일 */ +export const REVIEW_LOGIN_CODE = "482915"; + +/** + * 앱스토어 심사 대응 전용 로그인(ReviewLoginBottomSheet)으로 로그인한다. + * `/mypage/version`에서 "앱 버전" 라벨을 10회 연속 탭하면 진입점이 열린다. + */ +export async function openReviewLoginSheet(page: Page) { + await page.goto("/mypage/version"); + const versionLabel = page.getByText("앱 버전", { exact: true }); + for (let i = 0; i < 10; i++) { + await versionLabel.click(); + } + await expect(page.getByText("심사용 로그인")).toBeVisible(); +} + +/** 심사용 로그인을 완료하고 홈으로 리다이렉트될 때까지 기다린다. */ +export async function reviewLogin(page: Page) { + await openReviewLoginSheet(page); + await page.getByPlaceholder("6자리 코드 입력").fill(REVIEW_LOGIN_CODE); + await page.getByRole("button", { name: "로그인" }).click(); + await page.waitForURL("/"); +} diff --git a/apps/web-user/e2e/reservation.spec.ts b/apps/web-user/e2e/reservation.spec.ts new file mode 100644 index 00000000..f42b6808 --- /dev/null +++ b/apps/web-user/e2e/reservation.spec.ts @@ -0,0 +1,84 @@ +import { expect, test, type APIRequestContext, type Locator } from "@playwright/test"; + +import { reviewLogin } from "./helpers/review-login"; + +const BACKEND_ORIGIN = "http://localhost:3000"; +// apps/backend/src/test/e2e/fixture-seed.script.ts가 만드는 상품명과 동일 +const FIXTURE_PRODUCT_NAME = "[E2E] 테스트 케이크"; + +async function findFixtureProductId(request: APIRequestContext) { + const res = await request.get(`${BACKEND_ORIGIN}/v1/consumer/products`, { + params: { sortBy: "popular", page: 1, limit: 20, search: FIXTURE_PRODUCT_NAME }, + }); + expect(res.ok()).toBeTruthy(); + const body = await res.json(); + const product = body.data.data.find((p: { name: string }) => p.name === FIXTURE_PRODUCT_NAME); + expect(product, "fixture-seed.script.ts를 먼저 실행해 시드 상품이 있어야 합니다").toBeTruthy(); + return product.id as string; +} + +/** + * 사이즈/맛 Select는 픽업날짜·사이즈를 고르면 다음 단계 Select가 자동으로 열리는 + * UX가 있어(ReservationOptionsView의 openSignal 이펙트) 이미 열려 있을 수도, 아닐 수도 있다. + * 열려 있지 않을 때만 트리거를 클릭해 두 경우 모두 안전하게 처리한다. + */ +async function selectDropdownOption(sheet: Locator, triggerLabel: string, optionText: string) { + const option = sheet.getByRole("listitem").filter({ hasText: optionText }); + // 자동으로 열리는 경우 렌더링이 이 시점보다 늦게 반영될 수 있어, 짧게 기다렸다가 + // 그래도 안 열려 있으면 직접 트리거를 클릭한다(이미 열려 있는데 또 클릭하면 토글되어 닫혀버림). + const alreadyOpen = await option + .waitFor({ state: "visible", timeout: 1000 }) + .then(() => true) + .catch(() => false); + if (!alreadyOpen) { + await sheet.getByRole("button", { name: triggerLabel }).click(); + } + await option.click(); +} + +test("예약(주문) 생성 골든패스", async ({ page, request }) => { + await reviewLogin(page); + + const productId = await findFixtureProductId(request); + await page.goto(`/product/${productId}`); + + await page.getByRole("button", { name: "예약하기" }).click(); + + // BottomSheet는 열려 있을 때만 마운트되는 단일 인스턴스라, 이 안에서만 찾으면 + // 상품 상세 페이지 하단에 그대로 남아있는 "예약하기" CTA와 헷갈리지 않는다. + const sheet = page.locator(".animate-slide-up"); + await expect(sheet.getByText("상품 옵션 선택")).toBeVisible(); + + // 픽업날짜: 캘린더로 진입해 "내일"을 명시적으로 선택 + // (당일 선택 시 심야 실행이면 남은 시간 슬롯이 없을 수 있음) + await sheet.getByRole("button", { name: "픽업할 날짜와 시간을 선택해주세요" }).click(); + await expect(sheet.getByText("날짜 선택")).toBeVisible(); + const tomorrow = new Date(); + tomorrow.setDate(tomorrow.getDate() + 1); + await sheet.getByRole("button", { name: String(tomorrow.getDate()), exact: true }).click(); + await sheet + .getByRole("button", { name: /^(오전|오후) \d{1,2}:\d{2}$/ }) + .first() + .click(); + await sheet.getByRole("button", { name: "선택완료" }).click(); + + // 사이즈/맛 선택 (fixture-seed.script.ts가 심는 옵션과 동일 라벨) + await selectDropdownOption(sheet, "사이즈 선택", "미니"); + await selectDropdownOption(sheet, "맛 선택", "초콜릿"); + + await sheet.getByPlaceholder("가능한 10자 이내로 적어주세요.").fill("생일 축하해"); + + await sheet.getByRole("button", { name: "선택완료" }).click(); + await expect(sheet.getByText("주문확인")).toBeVisible(); + + // 심사용 계정 프로필의 전화번호(REVIEW_LOGIN_ACCOUNT_PHONE_MARKER)는 실제 휴대폰 형식이 + // 아니라서 prefill 여부와 무관하게 유효한 값으로 덮어써야 한다. + await sheet.getByPlaceholder("예약자명을 입력해주세요.").fill("테스트예약자"); + await sheet.getByPlaceholder("휴대폰 번호를 입력해주세요.").fill("01099998888"); + // Checkbox의 실제 input은 sr-only(시각적으로 숨김)라 label 텍스트를 클릭해 토글한다 + await sheet.getByText("모두 동의합니다", { exact: true }).click(); + + await sheet.getByRole("button", { name: "예약하기" }).click(); + + await page.waitForURL(/\/reservation\/complete\?orderId=.+/); +}); diff --git a/apps/web-user/e2e/review-login.spec.ts b/apps/web-user/e2e/review-login.spec.ts new file mode 100644 index 00000000..f956a39b --- /dev/null +++ b/apps/web-user/e2e/review-login.spec.ts @@ -0,0 +1,24 @@ +import { expect, test } from "@playwright/test"; + +import { openReviewLoginSheet, reviewLogin } from "./helpers/review-login"; + +test.describe("심사용 로그인", () => { + test("올바른 코드를 입력하면 홈으로 이동하고 로그인 상태가 저장된다", async ({ page }) => { + await reviewLogin(page); + + await expect(page).toHaveURL("/"); + const authState = await page.evaluate(() => localStorage.getItem("picake:auth")); + expect(authState).not.toBeNull(); + expect(JSON.parse(authState!).state.isAuthenticated).toBe(true); + }); + + test("잘못된 코드를 입력하면 오류가 표시되고 로그인되지 않는다", async ({ page }) => { + await openReviewLoginSheet(page); + + await page.getByPlaceholder("6자리 코드 입력").fill("000000"); + await page.getByRole("button", { name: "로그인" }).click(); + + await expect(page.getByText("오류")).toBeVisible(); + await expect(page).toHaveURL(/\/mypage\/version$/); + }); +}); diff --git a/apps/web-user/package.json b/apps/web-user/package.json index 409353a7..935caf24 100644 --- a/apps/web-user/package.json +++ b/apps/web-user/package.json @@ -14,7 +14,8 @@ "test": "vitest run", "test:watch": "vitest --project=unit", "test:unit": "vitest run --project=unit", - "test:storybook": "vitest run --project=storybook" + "test:storybook": "vitest run --project=storybook", + "test:e2e": "playwright test" }, "dependencies": { "@radix-ui/react-accordion": "^1.2.12", @@ -41,6 +42,7 @@ }, "devDependencies": { "@chromatic-com/storybook": "^4.1.3", + "@playwright/test": "^1.62.1", "@storybook/addon-a11y": "^10.4.6", "@storybook/addon-docs": "^10.4.6", "@storybook/addon-onboarding": "^10.4.6", @@ -56,7 +58,7 @@ "@vitest/coverage-v8": "^4.1.9", "autoprefixer": "^10.4.22", "eslint-plugin-storybook": "^10.4.6", - "playwright": "^1.57.0", + "playwright": "^1.62.1", "postcss": "^8.5.10", "storybook": "^10.4.6", "tailwindcss": "^3", diff --git a/apps/web-user/playwright.config.ts b/apps/web-user/playwright.config.ts new file mode 100644 index 00000000..879fce60 --- /dev/null +++ b/apps/web-user/playwright.config.ts @@ -0,0 +1,45 @@ +import path from "node:path"; + +import { defineConfig, devices } from "@playwright/test"; + +const repoRoot = path.resolve(__dirname, "../.."); + +/** + * 골든패스 E2E(심사용 로그인, 예약 생성)만 다루는 소규모 스위트. + * backend를 테스트 DB(.env.test)로 마이그레이션·시딩·기동까지 통째로 띄운 뒤, + * web-user dev 서버를 붙여서 실제 브라우저로 두 서버를 오가며 검증한다. + * + * 전제: backend/.env.test가 가리키는 로컬 Postgres 테스트 DB가 떠 있어야 함 + * (backend의 test:integration/test:e2e와 동일 전제). 같은 테스트 DB를 TRUNCATE하는 + * backend test:integration/test:e2e와 동시에 실행하지 말 것 — 서로 데이터를 지운다. + */ +export default defineConfig({ + testDir: "./e2e", + // 심사용 로그인 계정·시드 상품 등 테스트 DB 상태를 공유하므로 순차 실행만 허용 + workers: 1, + fullyParallel: false, + retries: process.env.CI ? 1 : 0, + reporter: [["list"]], + use: { + baseURL: "http://localhost:3001", + trace: "on-first-retry", + }, + projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }], + webServer: [ + { + command: + "yarn workspace @picake/backend db:migrate:test && yarn workspace @picake/backend seed:e2e-fixture && yarn workspace @picake/backend start:test", + cwd: repoRoot, + url: "http://localhost:3000/v1/consumer/products?sortBy=popular&page=1&limit=1", + reuseExistingServer: !process.env.CI, + timeout: 120_000, + }, + { + command: "yarn dev", + cwd: __dirname, + url: "http://localhost:3001", + reuseExistingServer: !process.env.CI, + timeout: 60_000, + }, + ], +}); diff --git a/apps/web-user/src/features/store/utils/store-business-calendar.util.ts b/apps/web-user/src/features/store/utils/store-business-calendar.util.ts index 4ed7e6b1..ed8d4f3d 100644 --- a/apps/web-user/src/features/store/utils/store-business-calendar.util.ts +++ b/apps/web-user/src/features/store/utils/store-business-calendar.util.ts @@ -21,7 +21,9 @@ function getSeoulWallClockForPickup(pickupUtc: Date): { day: "2-digit", hour: "2-digit", minute: "2-digit", - hour12: false, + // hour12: false만 쓰면 일부 ICU가 자정대(00:00~00:59)를 "24:00~24:59"로 포맷해 + // minuteOfDay가 1440대로 계산되는 문제가 있어 hourCycle로 직접 고정한다. + hourCycle: "h23", weekday: "short", }).formatToParts(pickupUtc); diff --git a/package.json b/package.json index 9626deac..b4916c5a 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "web-user:build": "yarn workspace @picake/web-user build", "web-user:storybook": "yarn workspace @picake/web-user storybook", "web-user:storybook:build": "yarn workspace @picake/web-user build-storybook", + "web-user:test:e2e": "yarn workspace @picake/web-user test:e2e", "web-seller:dev": "yarn workspace @picake/web-seller dev", "web-seller:build": "yarn workspace @picake/web-seller build", "web-seller:storybook": "yarn workspace @picake/web-seller storybook", diff --git a/yarn.lock b/yarn.lock index 4f6f9df3..1868ccfd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4856,6 +4856,7 @@ __metadata: resolution: "@picake/web-user@workspace:apps/web-user" dependencies: "@chromatic-com/storybook": "npm:^4.1.3" + "@playwright/test": "npm:^1.62.1" "@radix-ui/react-accordion": "npm:^1.2.12" "@radix-ui/react-slot": "npm:^1.2.4" "@sentry/nextjs": "npm:^10.63.0" @@ -4882,7 +4883,7 @@ __metadata: isomorphic-dompurify: "npm:^3.18.0" lucide-react: "npm:^0.554.0" next: "npm:15.5.18" - playwright: "npm:^1.57.0" + playwright: "npm:^1.62.1" postcss: "npm:^8.5.10" posthog-js: "npm:^1.396.6" react: "npm:19.1.2" @@ -4910,6 +4911,17 @@ __metadata: languageName: node linkType: hard +"@playwright/test@npm:^1.62.1": + version: 1.62.1 + resolution: "@playwright/test@npm:1.62.1" + dependencies: + playwright: "npm:1.62.1" + bin: + playwright: cli.js + checksum: 10c0/4b76f2f717723f84a73601f74fde97f5d92e4b5c869cf87c0f5628bf247fa1d18c9dc6c8e136463303233239e8ce58f902513d4281a9fabc3c426e911ded2c83 + languageName: node + linkType: hard + "@polka/url@npm:^1.0.0-next.24": version: 1.0.0-next.29 resolution: "@polka/url@npm:1.0.0-next.29" @@ -15605,6 +15617,30 @@ __metadata: languageName: node linkType: hard +"playwright-core@npm:1.62.1": + version: 1.62.1 + resolution: "playwright-core@npm:1.62.1" + bin: + playwright-core: cli.js + checksum: 10c0/a37d0f03bb73364cfd0eba704c4b3359b609c4779ae2649a88d8e2b3a29c7357dfcc58bdcf0cb68ddffdef7687abf78902c8356e89f189682c7e19be38e108f0 + languageName: node + linkType: hard + +"playwright@npm:1.62.1, playwright@npm:^1.62.1": + version: 1.62.1 + resolution: "playwright@npm:1.62.1" + dependencies: + fsevents: "npm:2.3.2" + playwright-core: "npm:1.62.1" + dependenciesMeta: + fsevents: + optional: true + bin: + playwright: cli.js + checksum: 10c0/4d3522cd46325f50c46f8874d31f70d036f6983228a9f8b9d68fc249e4c17464edcd97cc4adfbb24e712d1829c1386d3bc24e17f58a1ced94e7c423300c21845 + languageName: node + linkType: hard + "playwright@npm:^1.57.0": version: 1.57.0 resolution: "playwright@npm:1.57.0"