diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 247218091..692e71fd5 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -175,7 +175,7 @@ e2e-pleroma: NOTIFY_EMAIL: $E2E_ADMIN_EMAIL VITE_PROXY_TARGET: http://pleroma:4000 VITE_PROXY_ORIGIN: http://localhost:4000 - E2E_BASE_URL: http://localhost:8080 + E2E_BASE_URL: http://localhost:8099 script: - npm install -g yarn@1.22.22 - yarn --frozen-lockfile diff --git a/.woodpecker/test-e2e.yaml b/.woodpecker/test-e2e.yaml index c0bf103cd..a5289bd78 100644 --- a/.woodpecker/test-e2e.yaml +++ b/.woodpecker/test-e2e.yaml @@ -30,7 +30,7 @@ steps: environment: APT_CACHE_DIR: apt-cache DEBIAN_FRONTEND: noninteractive - E2E_BASE_URL: http://localhost:8080 + E2E_BASE_URL: http://localhost:8099 FF_NETWORK_PER_BUILD: "true" PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: "1" VITE_PROXY_ORIGIN: "http://pleroma:4000" diff --git a/docker-compose.e2e.yml b/docker-compose.e2e.yml index 75a4979a1..c49984684 100644 --- a/docker-compose.e2e.yml +++ b/docker-compose.e2e.yml @@ -39,6 +39,8 @@ services: interval: 5s timeout: 3s retries: 60 + ports: + - 4000:4000 e2e: build: @@ -51,7 +53,7 @@ services: CI: "1" VITE_PROXY_TARGET: http://pleroma:4000 VITE_PROXY_ORIGIN: http://localhost:4000 - E2E_BASE_URL: http://localhost:8080 + E2E_BASE_URL: http://localhost:8099 E2E_ADMIN_USERNAME: ${E2E_ADMIN_USERNAME:-admin} E2E_ADMIN_PASSWORD: ${E2E_ADMIN_PASSWORD:-adminadmin} command: ["yarn", "e2e:pw"] diff --git a/package.json b/package.json index cdeefc30c..f594d7a99 100644 --- a/package.json +++ b/package.json @@ -99,7 +99,7 @@ "msw": "2.14.6", "nightwatch": "3.12.2", "oxc": "^1.0.1", - "playwright": "1.57.0", + "playwright": "1.61.0", "postcss": "8.5.6", "postcss-html": "^1.5.0", "postcss-scss": "^4.0.6", diff --git a/src/api/helpers.js b/src/api/helpers.js index a7e1b624b..5fb11a183 100644 --- a/src/api/helpers.js +++ b/src/api/helpers.js @@ -98,41 +98,31 @@ export const promisedRequest = async ({ } } - let response = null - try { - response = await fetch(url, options) - const data = await (async () => { - const [contentType] = response.headers - .get('content-type') - .split(';') - .map((x) => x.toLowerCase().trim()) - const contentLength = parseInt(response.headers.get('content-length')) - if (contentLength === 0) return null + const response = await fetch(url, options) + const data = await (async () => { + const [contentType] = response.headers + .get('content-type') + .split(';') + .map((x) => x.toLowerCase().trim()) + const contentLength = parseInt(response.headers.get('content-length')) + if (contentLength === 0) return null - switch (contentType) { - case 'text/plain': - return await response.text() - case 'application/json': - return await response.json() - default: - return await response.bytes() - } - })() - - const { ok, status } = response - - if (ok) { - return { response, status, data } - } else { - throw new StatusCodeError( - response.status, - data, - { url, options }, - response, - ) + switch (contentType) { + case 'text/plain': + return await response.text() + case 'application/json': + return await response.json() + default: + return await response.bytes() } - } catch (error) { - throw new Error(error, { url, options }, response) + })() + + const { ok, status } = response + + if (ok) { + return { response, status, data } + } else { + throw new StatusCodeError(response.status, data, { url, options }, response) } } diff --git a/src/api/oauth.js b/src/api/oauth.js index 7ecb0cc3f..b774b3f6c 100644 --- a/src/api/oauth.js +++ b/src/api/oauth.js @@ -2,8 +2,6 @@ import { reduce } from 'lodash' import { paramsString, promisedRequest } from './helpers.js' -import { StatusCodeError } from 'src/services/errors/errors.js' - const REDIRECT_URI = `${window.location.origin}/oauth-callback` export const MASTODON_APP_VERIFY_URL = '/api/v1/apps/verify_credentials' diff --git a/src/api/public.js b/src/api/public.js index 12af848d7..bd3f56fce 100644 --- a/src/api/public.js +++ b/src/api/public.js @@ -1,10 +1,6 @@ -import { concat, each, last, map } from 'lodash' - import { paramsString, promisedRequest } from './helpers.js' import { - parseAttachment, - parseChat, parseLinkHeaderPagination, parseNotification, parseSource, @@ -143,8 +139,6 @@ const MASTODON_SEARCH_2 = ({ `/api/v2/search${paramsString({ q, resolve, limit, offset, following, type, withRelationships, accountId, excludeUnreviewed })}` const MASTODON_USER_SEARCH_URL = ({ q, resolve }) => `/api/v1/accounts/search${paramsString({ q, resolve })}` -const MASTODON_STREAMING = ({ accessToken, stream }) => - `/api/v1/streaming${paramsString({ accessToken, stream })}` const MASTODON_KNOWN_DOMAIN_LIST_URL = '/api/v1/instance/peers' const PLEROMA_EMOJI_REACTIONS_URL = (id) => `/api/v1/pleroma/statuses/${id}/reactions` @@ -487,168 +481,6 @@ export const search2 = ({ export const fetchKnownDomains = ({ credentials }) => promisedRequest({ url: MASTODON_KNOWN_DOMAIN_LIST_URL, credentials }) -export const getMastodonSocketURI = ({ credentials, stream }, base) => { - return base + MASTODON_STREAMING({ accessToken: credentials, stream }) -} - -const MASTODON_STREAMING_EVENTS = new Set([ - 'update', - 'notification', - 'delete', - 'filters_changed', - 'status.update', -]) - -const PLEROMA_STREAMING_EVENTS = new Set([ - 'pleroma:chat_update', - 'pleroma:respond', -]) - -// A thin wrapper around WebSocket API that allows adding a pre-processor to it -// Uses EventTarget and a CustomEvent to proxy events -export const ProcessedWS = ({ - url, - preprocessor = handleMastoWS, - id = 'Unknown', - credentials, -}) => { - const eventTarget = new EventTarget() - const socket = new WebSocket(url) - if (!socket) throw new Error(`Failed to create socket ${id}`) - const proxy = (original, eventName, processor = (a) => a) => { - original.addEventListener(eventName, (eventData) => { - eventTarget.dispatchEvent( - new CustomEvent(eventName, { detail: processor(eventData) }), - ) - }) - } - socket.addEventListener('open', (wsEvent) => { - console.debug(`[WS][${id}] Socket connected`, wsEvent) - if (credentials) { - socket.send( - JSON.stringify({ - type: 'pleroma:authenticate', - token: credentials, - }), - ) - } - }) - socket.addEventListener('error', (wsEvent) => { - console.debug(`[WS][${id}] Socket errored`, wsEvent) - }) - socket.addEventListener('close', (wsEvent) => { - console.debug( - `[WS][${id}] Socket disconnected with code ${wsEvent.code}`, - wsEvent, - ) - }) - // Commented code reason: very spammy, uncomment to enable message debug logging - /* - socket.addEventListener('message', (wsEvent) => { - console.debug( - `[WS][${id}] Message received`, - wsEvent - ) - }) - /**/ - - const onAuthenticated = () => { - eventTarget.dispatchEvent(new CustomEvent('pleroma:authenticated')) - } - - proxy(socket, 'open') - proxy(socket, 'close') - proxy(socket, 'message', (event) => preprocessor(event, { onAuthenticated })) - proxy(socket, 'error') - - // 1000 = Normal Closure - eventTarget.close = () => { - socket.close(1000, 'Shutting down socket') - } - eventTarget.getState = () => socket.readyState - eventTarget.subscribe = (stream, args = {}) => { - console.debug(`[WS][${id}] Subscribing to stream ${stream} with args`, args) - socket.send( - JSON.stringify({ - type: 'subscribe', - stream, - ...args, - }), - ) - } - eventTarget.unsubscribe = (stream, args = {}) => { - console.debug( - `[WS][${id}] Unsubscribing from stream ${stream} with args`, - args, - ) - socket.send( - JSON.stringify({ - type: 'unsubscribe', - stream, - ...args, - }), - ) - } - - return eventTarget -} - -export const handleMastoWS = ( - wsEvent, - { - onAuthenticated = () => { - /* no-op */ - }, - } = {}, -) => { - const { data } = wsEvent - if (!data) return - const parsedEvent = JSON.parse(data) - const { event, payload } = parsedEvent - if ( - MASTODON_STREAMING_EVENTS.has(event) || - PLEROMA_STREAMING_EVENTS.has(event) - ) { - // MastoBE and PleromaBE both send payload for delete as a PLAIN string - if (event === 'delete') { - return { event, id: payload } - } - const data = payload ? JSON.parse(payload) : null - if (event === 'pleroma:respond') { - if (data.type === 'pleroma:authenticate') { - if (data.result === 'success') { - console.debug('[WS] Successfully authenticated') - onAuthenticated() - } else { - console.error('[WS] Unable to authenticate:', data.error) - wsEvent.target.close() - } - } - return null - } else if (event === 'update') { - return { event, status: parseStatus(data) } - } else if (event === 'status.update') { - return { event, status: parseStatus(data) } - } else if (event === 'notification') { - return { event, notification: parseNotification(data) } - } else if (event === 'pleroma:chat_update') { - return { event, chatUpdate: parseChat(data) } - } - } else { - console.warn('Unknown event', wsEvent) - return null - } -} - -export const WSConnectionStatus = Object.freeze({ - JOINED: 1, - CLOSED: 2, - ERROR: 3, - DISABLED: 4, - STARTING: 5, - STARTING_INITIAL: 6, -}) - export const fetchScrobbles = ({ accountId, limit = 1 }) => promisedRequest({ url: PLEROMA_SCROBBLES_URL(accountId, { limit }), diff --git a/src/api/user.js b/src/api/user.js index 127debdea..ce41c0c21 100644 --- a/src/api/user.js +++ b/src/api/user.js @@ -1,4 +1,4 @@ -import { concat, each, last, map } from 'lodash' +import { concat, last } from 'lodash' import { paramsString, promisedRequest } from './helpers.js' import { fetchFriends, MASTODON_STATUS_URL } from './public.js' diff --git a/src/api/websocket.js b/src/api/websocket.js new file mode 100644 index 000000000..44f3d391a --- /dev/null +++ b/src/api/websocket.js @@ -0,0 +1,176 @@ +import { paramsString, promisedRequest } from './helpers.js' + +import { + parseChat, + parseNotification, + parseStatus, +} from 'src/services/entity_normalizer/entity_normalizer.service.js' + +const MASTODON_STREAMING = ({ accessToken, stream }) => + `/api/v1/streaming${paramsString({ accessToken, stream })}` + +export const getMastodonSocketURI = ({ credentials, stream }) => { + return MASTODON_STREAMING({ accessToken: credentials, stream }) +} + +const MASTODON_STREAMING_EVENTS = new Set([ + 'update', + 'notification', + 'delete', + 'filters_changed', + 'status.update', +]) + +const PLEROMA_STREAMING_EVENTS = new Set([ + 'pleroma:chat_update', + 'pleroma:respond', +]) + +// A thin wrapper around WebSocket API that allows adding a pre-processor to it +// Uses EventTarget and a CustomEvent to proxy events +export const ProcessedWS = ({ + url, + preprocessor = handleMastoWS, + id = 'Unknown', + credentials, +}) => { + const eventTarget = new EventTarget() + const socket = new WebSocket(url) + if (!socket) throw new Error(`Failed to create socket ${id}`) + const proxy = (original, eventName, processor = (a) => a) => { + original.addEventListener(eventName, (eventData) => { + eventTarget.dispatchEvent( + new CustomEvent(eventName, { detail: processor(eventData) }), + ) + }) + } + socket.addEventListener('open', (wsEvent) => { + console.debug(`[WS][${id}] Socket connected`, wsEvent) + if (credentials) { + socket.send( + JSON.stringify({ + type: 'pleroma:authenticate', + token: credentials, + }), + ) + } + }) + socket.addEventListener('error', (wsEvent) => { + console.debug(`[WS][${id}] Socket errored`, wsEvent) + }) + socket.addEventListener('close', (wsEvent) => { + console.debug( + `[WS][${id}] Socket disconnected with code ${wsEvent.code}`, + wsEvent, + ) + }) + // Commented code reason: very spammy, uncomment to enable message debug logging + /* + socket.addEventListener('message', (wsEvent) => { + console.debug( + `[WS][${id}] Message received`, + wsEvent + ) + }) + /**/ + + const onAuthenticated = () => { + eventTarget.dispatchEvent(new CustomEvent('pleroma:authenticated')) + } + + proxy(socket, 'open') + proxy(socket, 'close') + proxy(socket, 'message', (event) => preprocessor(event, { onAuthenticated })) + proxy(socket, 'error') + + // 1000 = Normal Closure + eventTarget.close = () => { + socket.close(1000, 'Shutting down socket') + } + eventTarget.getState = () => socket.readyState + eventTarget.subscribe = (stream, args = {}) => { + console.debug(`[WS][${id}] Subscribing to stream ${stream} with args`, args) + socket.send( + JSON.stringify({ + type: 'subscribe', + stream, + ...args, + }), + ) + } + eventTarget.unsubscribe = (stream, args = {}) => { + console.debug( + `[WS][${id}] Unsubscribing from stream ${stream} with args`, + args, + ) + socket.send( + JSON.stringify({ + type: 'unsubscribe', + stream, + ...args, + }), + ) + } + + return eventTarget +} + +export const handleMastoWS = ( + wsEvent, + { + onAuthenticated = () => { + /* no-op */ + }, + } = {}, +) => { + const { data } = wsEvent + if (!data) return + const parsedEvent = JSON.parse(data) + const { event, payload } = parsedEvent + if ( + MASTODON_STREAMING_EVENTS.has(event) || + PLEROMA_STREAMING_EVENTS.has(event) + ) { + // MastoBE and PleromaBE both send payload for delete as a PLAIN string + if (event === 'delete') { + return { event, id: payload } + } + const data = payload ? JSON.parse(payload) : null + if (event === 'pleroma:respond') { + if (data.type === 'pleroma:authenticate') { + if (data.result === 'success') { + console.debug('[WS] Successfully authenticated') + onAuthenticated() + } else { + if (data.error === 'already_authenticated') { + onAuthenticated() + } else { + console.error('[WS] Unable to authenticate:', data.error) + wsEvent.target.close() + } + } + } + return null + } else if (event === 'update') { + return { event, status: parseStatus(data) } + } else if (event === 'status.update') { + return { event, status: parseStatus(data) } + } else if (event === 'notification') { + return { event, notification: parseNotification(data) } + } else if (event === 'pleroma:chat_update') { + return { event, chatUpdate: parseChat(data) } + } + } else { + console.warn('Unknown event', wsEvent) + return null + } +} + +export const WSConnectionStatus = Object.freeze({ + JOINED: 1, + CLOSED: 2, + ERROR: 3, + DISABLED: 4, + STARTING: 5, + STARTING_INITIAL: 6, +}) diff --git a/src/components/chat/chat.js b/src/components/chat/chat.js index 68306acbe..16a03ab1d 100644 --- a/src/components/chat/chat.js +++ b/src/components/chat/chat.js @@ -23,7 +23,7 @@ import { getOrCreateChat, sendChatMessage, } from 'src/api/chats.js' -import { WSConnectionStatus } from 'src/api/public.js' +import { WSConnectionStatus } from 'src/api/websocket.js' import { library } from '@fortawesome/fontawesome-svg-core' import { faChevronDown, faChevronLeft } from '@fortawesome/free-solid-svg-icons' diff --git a/src/components/conversation/conversation.js b/src/components/conversation/conversation.js index 281f2b573..54bcbd373 100644 --- a/src/components/conversation/conversation.js +++ b/src/components/conversation/conversation.js @@ -10,11 +10,8 @@ import { useInterfaceStore } from 'src/stores/interface' import { useMergedConfigStore } from 'src/stores/merged_config.js' import { useOAuthStore } from 'src/stores/oauth.js' -import { - fetchConversation, - fetchStatus, - WSConnectionStatus, -} from 'src/api/public.js' +import { fetchConversation, fetchStatus } from 'src/api/public.js' +import { WSConnectionStatus } from 'src/api/websocket.js' import { library } from '@fortawesome/fontawesome-svg-core' import { diff --git a/src/modules/api.js b/src/modules/api.js index a36e21c05..ca1aacf05 100644 --- a/src/modules/api.js +++ b/src/modules/api.js @@ -8,12 +8,12 @@ import { useInterfaceStore } from 'src/stores/interface.js' import { useOAuthStore } from 'src/stores/oauth.js' import { useShoutStore } from 'src/stores/shout.js' +import { fetchTimeline } from 'src/api/public.js' import { - fetchTimeline, getMastodonSocketURI, ProcessedWS, WSConnectionStatus, -} from 'src/api/public.js' +} from 'src/api/websocket.js' import followRequestFetcher from 'src/services/follow_request_fetcher/follow_request_fetcher.service' import notificationsFetcher from 'src/services/notifications_fetcher/notifications_fetcher.service.js' import timelineFetcher from 'src/services/timeline_fetcher/timeline_fetcher.service.js' @@ -97,9 +97,8 @@ const api = { const { state, commit, dispatch, rootState } = store const timelineData = rootState.statuses.timelines.friends - const serv = useInstanceStore().server.replace('http', 'ws') const credentials = useOAuthStore().token - const url = getMastodonSocketURI({ credentials }, serv) + const url = getMastodonSocketURI({ credentials }) state.mastoUserSocket = ProcessedWS({ url, diff --git a/src/services/errors/errors.js b/src/services/errors/errors.js index 00dfd3712..a28f31775 100644 --- a/src/services/errors/errors.js +++ b/src/services/errors/errors.js @@ -13,7 +13,7 @@ function humanizeErrors(errors) { export function StatusCodeError(statusCode, body, options, response) { this.name = 'StatusCodeError' this.statusCode = statusCode - this.statusText = body.error.error || body.error + this.statusText = body.error || body this.details = JSON && JSON.stringify ? JSON.stringify(body) : body this.errorData = body.error this.message = this.statusCode + ' - ' + this.statusText diff --git a/src/stores/admin_settings.js b/src/stores/admin_settings.js index 06bcab9ae..5aa91c819 100644 --- a/src/stores/admin_settings.js +++ b/src/stores/admin_settings.js @@ -87,22 +87,20 @@ export const useAdminSettingsStore = defineStore('adminSettings', { loadAdminStuff() { getInstanceDBConfig({ credentials: useOAuthStore().token, - }).then(({ data: backendDbConfig }) => { - if (backendDbConfig.error) { - if (backendDbConfig.error.status === 400) { - backendDbConfig.error.json().then((errorJson) => { - if (/configurable_from_database/.test(errorJson.error)) { - this.setInstanceAdminNoDbConfig() - } - }) - } - } else { + }) + .then(({ data: backendDbConfig }) => this.setInstanceAdminSettings({ credentials: useOAuthStore().token, backendDbConfig, - }) - } - }) + }), + ) + .catch(({ statusCode, statusText }) => { + if (statusCode === 400) { + if (/configurable_from_database/.test(statusText)) { + this.setInstanceAdminNoDbConfig() + } + } + }) if (this.descriptions === null) { getInstanceConfigDescriptions({ credentials: useOAuthStore().token, diff --git a/src/stores/bookmark_folders.js b/src/stores/bookmark_folders.js index 6ffec807b..713a21d00 100644 --- a/src/stores/bookmark_folders.js +++ b/src/stores/bookmark_folders.js @@ -27,8 +27,8 @@ export const useBookmarkFoldersStore = defineStore('bookmarkFolders', { }, actions: { startFetching() { - promiseInterval(() => { - this.fetcher = fetchBookmarkFolders({ + this.fetcher = promiseInterval(() => { + fetchBookmarkFolders({ credentials: useOAuthStore().token, }) .then(({ data: folders }) => this.setBookmarkFolders(folders)) diff --git a/src/stores/lists.js b/src/stores/lists.js index 7634f1061..37471d46e 100644 --- a/src/stores/lists.js +++ b/src/stores/lists.js @@ -34,8 +34,8 @@ export const useListsStore = defineStore('lists', { }, actions: { startFetching() { - promiseInterval(() => { - this.fetcher = fetchLists({ + this.fetcher = promiseInterval(() => { + fetchLists({ credentials: useOAuthStore().token, }) .then(({ data: lists }) => this.setLists(lists)) diff --git a/test/e2e-playwright/playwright.config.mjs b/test/e2e-playwright/playwright.config.mjs index 04747ee77..51a4de21e 100644 --- a/test/e2e-playwright/playwright.config.mjs +++ b/test/e2e-playwright/playwright.config.mjs @@ -1,7 +1,7 @@ /* global process */ import { defineConfig, devices } from 'playwright/test' -const baseURL = process.env.E2E_BASE_URL || 'http://localhost:8080' +const baseURL = process.env.E2E_BASE_URL || 'http://localhost:8099' export default defineConfig({ testDir: './specs', @@ -25,12 +25,13 @@ export default defineConfig({ video: 'retain-on-failure', }, webServer: { - command: 'yarn dev -- --host 0.0.0.0 --port 8080 --strictPort', + command: 'yarn dev -- --host 0.0.0.0 --port $PORT --strictPort', url: baseURL, reuseExistingServer: !process.env.CI, timeout: 120_000, env: { ...process.env, + PORT: process.env.PORT || '8099', VITE_PROXY_TARGET: process.env.VITE_PROXY_TARGET || 'http://localhost:4000', VITE_PROXY_ORIGIN: diff --git a/test/e2e/specs/test.js b/test/e2e/specs/test.js index f8993989b..ba3e757fd 100644 --- a/test/e2e/specs/test.js +++ b/test/e2e/specs/test.js @@ -4,7 +4,7 @@ module.exports = { 'default e2e tests': function (browser) { // automatically uses dev Server port from /config.index.js - // default: http://localhost:8080 + // default: http://localhost:8099 // see nightwatch.conf.js const devServer = browser.globals.devServerURL diff --git a/test/unit/specs/stores/oauth.spec.js b/test/unit/specs/stores/oauth.spec.js index c4f4ba978..828175c9f 100644 --- a/test/unit/specs/stores/oauth.spec.js +++ b/test/unit/specs/stores/oauth.spec.js @@ -69,6 +69,12 @@ describe('oauth store', () => { it('should use create an app and record client id and secret', async ({ worker, }) => { + worker.use( + http.post(MASTODON_APP_URL, () => { + return HttpResponse.text('Throttled', { status: 429 }) + }), + ) + const store = useOAuthStore() worker.use(...authApis()) const app = await store.createApp() diff --git a/vite.config.js b/vite.config.js index 6cbba4a53..64859ebb1 100644 --- a/vite.config.js +++ b/vite.config.js @@ -73,6 +73,7 @@ export default defineConfig(async ({ mode, command }) => { const settings = await getLocalDevSettings() const target = settings.target || 'http://localhost:4000/' const origin = settings.origin || target + const targetSW = target.replace(/^http/, 'ws') const transformSW = getTransformSWSettings(settings) const proxy = { '/api': { @@ -80,6 +81,7 @@ export default defineConfig(async ({ mode, command }) => { changeOrigin: true, cookieDomainRewrite: 'localhost', ws: true, + rewriteWsOrigin: true, }, '/auth': { // Mastodon password reset lives here @@ -98,20 +100,21 @@ export default defineConfig(async ({ mode, command }) => { changeOrigin: true, cookieDomainRewrite: 'localhost', }, - '/socket': { - target, - changeOrigin: true, - cookieDomainRewrite: 'localhost', - ws: true, - headers: { - Origin: origin, - }, - }, '/oauth': { target, changeOrigin: true, cookieDomainRewrite: 'localhost', }, + '/socket': { + target: targetSW, + changeOrigin: true, + cookieDomainRewrite: 'localhost', + rewriteWsOrigin: true, + ws: true, + headers: { + Origin: origin, + }, + }, } const swSrc = 'src/sw.js' diff --git a/yarn.lock b/yarn.lock index b8c3e3293..b60f88b7b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7289,17 +7289,17 @@ pkg-types@^1.3.1: mlly "^1.7.4" pathe "^2.0.1" -playwright-core@1.57.0: - version "1.57.0" - resolved "https://registry.yarnpkg.com/playwright-core/-/playwright-core-1.57.0.tgz#3dcc9a865af256fa9f0af0d67fc8dd54eecaebf5" - integrity sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ== +playwright-core@1.61.0: + version "1.61.0" + resolved "https://registry.yarnpkg.com/playwright-core/-/playwright-core-1.61.0.tgz#caf8078b2a39cd7738dc75ec11cb3b47f385c3f0" + integrity sha512-caX7TrY3Ml6egyDX0WUcTHDxodl/b51y5wJOdCEA36QviK/s2g081hvmGs8eaE3DWb6NYZQ6BjO/QkNRPenoPA== -playwright@1.57.0: - version "1.57.0" - resolved "https://registry.yarnpkg.com/playwright/-/playwright-1.57.0.tgz#74d1dacff5048dc40bf4676940b1901e18ad0f46" - integrity sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw== +playwright@1.61.0: + version "1.61.0" + resolved "https://registry.yarnpkg.com/playwright/-/playwright-1.61.0.tgz#7082df3df08ffa82b11420ea5fae84a40bd16e3f" + integrity sha512-Z+7BeeqQPRRzklHsVFP4KTGIyMxKUmfeRA4WisM6G3/XW6nwGeX6fX9qYaDa+CiUqpOkb2f6X3nar05R3kSuJQ== dependencies: - playwright-core "1.57.0" + playwright-core "1.61.0" optionalDependencies: fsevents "2.3.2"