From 5f850b6ce061ab97cbf3091544abd845029a1a13 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Fri, 16 Oct 2026 13:52:18 -0300 Subject: [PATCH 1/5] Show a reel's track even when it was saved with only its id The composer now fills in a picked track's name, cover and preview when they are missing (an MCP or API post may send only meta.audio.id), and the unsaved-changes guard compares a track by its id and volumes only, so details that arrive after the first click never ask before closing. The MCP descriptions name the instagram-facebook platform, ask to copy the track details into meta.audio, and explain that collaborators must accept the invite. --- .../SocialAccount/GetInstagramAudioTool.php | 2 +- .../SearchInstagramAudioTool.php | 2 +- app/Support/PostPlatformMetaRules.php | 2 +- .../posts/editor/InstagramSettings.vue | 22 ++++- .../composer/useComposerUnsavedChanges.ts | 43 ++++++++-- tests/Browser/ComposerUnsavedChangesTest.php | 86 ++++++++++++++++++- tests/Feature/Mcp/ToolDocumentationTest.php | 20 +++++ 7 files changed, 163 insertions(+), 14 deletions(-) diff --git a/app/Mcp/Tools/SocialAccount/GetInstagramAudioTool.php b/app/Mcp/Tools/SocialAccount/GetInstagramAudioTool.php index 090fdad48d..808d8b4438 100644 --- a/app/Mcp/Tools/SocialAccount/GetInstagramAudioTool.php +++ b/app/Mcp/Tools/SocialAccount/GetInstagramAudioTool.php @@ -25,7 +25,7 @@ #[IsReadOnly(true)] #[IsDestructive(false)] #[IsOpenWorld(true)] -#[Description('Get one Instagram audio asset by id (the meta.audio.id of a reel, or an id from search-instagram-audio-tool): title, artist, duration and fresh cover and preview links, which Instagram expires. Fails when the track is no longer available. Requires an Instagram account connected through Facebook.')] +#[Description('Get one Instagram audio asset by id (the meta.audio.id of a reel, or an id from search-instagram-audio-tool): title, artist, duration and fresh cover and preview links, which Instagram expires. Fails when the track is no longer available. Requires an Instagram account connected through Facebook (platform instagram-facebook).')] class GetInstagramAudioTool extends Tool { use AuthorizesMcpTool; diff --git a/app/Mcp/Tools/SocialAccount/SearchInstagramAudioTool.php b/app/Mcp/Tools/SocialAccount/SearchInstagramAudioTool.php index 000ad13908..a977be8986 100644 --- a/app/Mcp/Tools/SocialAccount/SearchInstagramAudioTool.php +++ b/app/Mcp/Tools/SocialAccount/SearchInstagramAudioTool.php @@ -27,7 +27,7 @@ #[IsReadOnly(true)] #[IsDestructive(false)] #[IsOpenWorld(true)] -#[Description('Search Instagram audio (licensed music and original sounds) to attach to a reel, for an Instagram account connected through Facebook. Omit q for what is trending. Use a returned id as meta.audio.id (with optional title, artist, audio_volume and video_volume from 0 to 100) on an instagram_reel post. Results are cached for ten minutes; a workspace may make '.InstagramAudio::LOOKUPS_PER_MINUTE.' uncached lookups per minute.')] +#[Description('Search Instagram audio (licensed music and original sounds) to attach to a reel, for an Instagram account connected through Facebook (platform instagram-facebook). Omit q for what is trending. Use a returned id as meta.audio.id on an instagram_reel post, and copy its title, artist, cover_url and preview_url into meta.audio so the app can show the track; audio_volume and video_volume go from 0 to 100. Results are cached for ten minutes; a workspace may make '.InstagramAudio::LOOKUPS_PER_MINUTE.' uncached lookups per minute.')] class SearchInstagramAudioTool extends Tool { use AuthorizesMcpTool; diff --git a/app/Support/PostPlatformMetaRules.php b/app/Support/PostPlatformMetaRules.php index 45adf5e9b1..63ebdb0a21 100644 --- a/app/Support/PostPlatformMetaRules.php +++ b/app/Support/PostPlatformMetaRules.php @@ -217,7 +217,7 @@ public static function documentation(): string 'Pinterest: board_id (required to publish — call list-pinterest-boards-tool first, or create-pinterest-board-tool), title (≤100), link (destination URL). Pin description comes from the post content; a video pin cover frame is the media item meta.cover_offset_ms.', 'Discord: channel_id (required to publish — call list-discord-channels-tool first), mentions ([{token,label}], e.g. {token: "@everyone"} or {token: "<@&roleId>"}), embeds (up to 10 [{title ≤256, description ≤4096, url, image (URL), color (#RRGGBB)}]).', 'YouTube Shorts: title (≤100, no < or >; omitted, it is derived from the first non-empty line of the content with < and > removed, cut to 100 characters), description (plain text, at most 5000 bytes; omit or null to use the content), category_id (YouTube category id: '.collect(Category::cases())->map(fn (Category $category): string => "{$category->value}=".__($category->labelKey(), [], 'en'))->implode(', ').'; default '.Category::DEFAULT->value.'), privacy_status (public|unlisted|private, default public), license (youtube|creativeCommon), notify_subscribers (default true), embeddable (default true), made_for_kids (default false), is_ai_generated (discloses altered or synthetic content).', - 'Instagram: is_ai_generated (feed, reels, stories, carousels), share_to_feed (instagram_reel only, default true), collaborators (up to '.self::INSTAGRAM_MAX_COLLABORATORS.' Instagram usernames invited as co-authors; feed, reels and carousels, not stories), trial_graduation (instagram_reel only: MANUAL|SS_PERFORMANCE publishes a trial reel shown only to non-followers, graduated by hand in the app or by Instagram when it performs well; not with collaborators, and only on accounts where Instagram offers trial reels), audio (instagram_reel only, Instagram connected through Facebook: {id} of an Instagram audio track from search-instagram-audio-tool, with optional title, artist, cover_url and preview_url copied from that result so the app can show the track, and audio_volume and video_volume from 0 to 100, default 100). An instagram_feed post with a single video publishes as a Reel. People tags are the media item meta.user_tags and a video cover frame is meta.cover_offset_ms.', + 'Instagram: is_ai_generated (feed, reels, stories, carousels), share_to_feed (instagram_reel only, default true), collaborators (up to '.self::INSTAGRAM_MAX_COLLABORATORS.' Instagram usernames invited as co-authors; feed, reels and carousels, not stories; the post shows on a collaborator\'s profile only after they accept the invite in the Instagram app, and a username that does not exist is only refused when the post publishes, so confirm the usernames first), trial_graduation (instagram_reel only: MANUAL|SS_PERFORMANCE publishes a trial reel shown only to non-followers, graduated by hand in the app or by Instagram when it performs well; not with collaborators, and only on accounts where Instagram offers trial reels), audio (instagram_reel only, Instagram connected through Facebook, platform instagram-facebook: {id} of an Instagram audio track from search-instagram-audio-tool, with optional title, artist, cover_url and preview_url copied from that result so the app can show the track, and audio_volume and video_volume from 0 to 100, default 100). An instagram_feed post with a single video publishes as a Reel. People tags are the media item meta.user_tags and a video cover frame is meta.cover_offset_ms.', 'X: is_ai_generated (sent as made_with_ai, discloses AI-generated media on the post).', 'Threads: topic_tag (1-50 characters after a leading # is dropped, no . or &). Use content_type threads_ghost_post for a text-only post archived after 24 hours (no media, no topic).', 'Facebook, Bluesky, LinkedIn: link_preview (default true; false publishes a text post with a link without its preview card).', diff --git a/resources/js/components/posts/editor/InstagramSettings.vue b/resources/js/components/posts/editor/InstagramSettings.vue index 0180c50c4d..0d03d51fa8 100644 --- a/resources/js/components/posts/editor/InstagramSettings.vue +++ b/resources/js/components/posts/editor/InstagramSettings.vue @@ -215,15 +215,17 @@ const refreshSelectedAudio = async (): Promise => { ); const current = selectedAudio.value; - if (!fresh || current?.id !== audio.id) { + if (!fresh || fresh.id !== audio.id || current?.id !== audio.id) { return false; } updateMeta({ audio: { ...current, - cover_url: fresh.cover_url, - preview_url: fresh.preview_url, + title: current.title || fresh.title, + artist: current.artist || fresh.artist, + cover_url: fresh.cover_url || current.cover_url, + preview_url: fresh.preview_url || current.preview_url, }, }); @@ -233,6 +235,20 @@ const refreshSelectedAudio = async (): Promise => { } }; +const missesTrackDetails = (audio: InstagramAudio | null): boolean => + Boolean(audio?.id) && + (!audio?.title || !audio.cover_url || !audio.preview_url); + +watch( + () => [selectedAudio.value?.id, isReel.value] as const, + () => { + if (isReel.value && missesTrackDetails(selectedAudio.value)) { + void refreshSelectedAudio(); + } + }, + { immediate: true }, +); + const onSelectedLinkBroken = (url: string | null | undefined): void => { void refreshSelectedAudio().then((refreshed) => { if (!refreshed) markBroken(url); diff --git a/resources/js/composables/composer/useComposerUnsavedChanges.ts b/resources/js/composables/composer/useComposerUnsavedChanges.ts index 3756a84d4c..b6abb933bd 100644 --- a/resources/js/composables/composer/useComposerUnsavedChanges.ts +++ b/resources/js/composables/composer/useComposerUnsavedChanges.ts @@ -2,6 +2,7 @@ import { nextTick, ref, watch } from 'vue'; import { useUnsavedChanges } from '@/composables/composer/useUnsavedChanges'; import type { usePostComposition } from '@/composables/usePostComposition'; +import type { InstagramAudio } from '@/types/instagram'; import { PostStatus } from '@/types/post'; interface ComposerUnsavedChangesOptions { @@ -17,14 +18,35 @@ interface ComposerUnsavedChangesOptions { uploading: () => boolean; } +/** + * The parts of a picked Instagram track the person chooses: the track and its + * volumes. Its name, cover and preview come with the id and are filled in or + * renewed by the composer on its own, so they never count as a change. + */ +const chosenTrack = (meta: Record): Record => { + const audio = meta.audio as InstagramAudio | null | undefined; + + if (!audio) return meta; + + return { + ...meta, + audio: { + id: audio.id, + audio_volume: audio.audio_volume, + video_volume: audio.video_volume, + }, + }; +}; + /** * The composer's unsaved-changes guard: compares what saving would send with * what it would have sent when the post opened. A file still uploading counts * too: it joins the post only once the upload finishes. * - * Settings fill some values in on their own once their data loads (a renewed - * music cover, the TikTok creator's limits), so the opened state is taken - * again at the person's first interaction and nothing counts before it. + * Settings fill some values in on their own once their data loads (the TikTok + * creator's limits), so the opened state is taken again at the person's first + * interaction and nothing counts before it. A music track's name, cover and + * preview can arrive after that interaction, so they are left out entirely. */ export const useComposerUnsavedChanges = ( options: ComposerUnsavedChangesOptions, @@ -34,12 +56,19 @@ export const useComposerUnsavedChanges = ( /** What saving would send, so any change the person makes counts. */ const snapshot = (): string => { const mode = scheduleMode(); + const submission = composition.materialize( + PostStatus.Scheduled, + mode === 'next' || mode === 'top' ? mode : null, + ); return JSON.stringify({ - submission: composition.materialize( - PostStatus.Scheduled, - mode === 'next' || mode === 'top' ? mode : null, - ), + submission: { + ...submission, + destinations: submission.destinations.map((destination) => ({ + ...destination, + meta: chosenTrack(destination.meta ?? {}), + })), + }, scheduleMode: mode, }); }; diff --git a/tests/Browser/ComposerUnsavedChangesTest.php b/tests/Browser/ComposerUnsavedChangesTest.php index c7362ebbe2..a14995f6e2 100644 --- a/tests/Browser/ComposerUnsavedChangesTest.php +++ b/tests/Browser/ComposerUnsavedChangesTest.php @@ -545,7 +545,7 @@ function openUnsavedChangesFromList(Post $post, ?string $from = null, string $re $post = Post::factory()->forAccount($account, ContentType::InstagramReel)->create([ 'user_id' => $user->id, 'content' => 'Reel with music', - 'meta' => ['audio' => ['id' => '846373660299207', 'title' => 'Summer Song', 'cover_url' => $pixel]], + 'meta' => ['audio' => ['id' => '846373660299207', 'title' => 'Summer Song', 'cover_url' => $pixel, 'preview_url' => 'https://example.com/summer-song.m4a']], ]); $this->actingAs($user); @@ -563,6 +563,90 @@ function openUnsavedChangesFromList(Post $post, ?string $from = null, string $re ->assertNoJavaScriptErrors(); }); +function seedUnsavedChangesReelWithBareTrack(): Post +{ + $pixel = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=='; + Http::fake([ + config('trypost.platforms.instagram-facebook.graph_api').'/846373660299207*' => Http::response([ + 'audio_id' => '846373660299207', + 'title' => 'Summer Song', + 'display_artist' => 'The Band', + 'cover_artwork_thumbnail_uri' => "{$pixel}#fresh", + 'download_url' => 'https://example.com/summer-song.m4a', + ]), + '*' => Http::response([], 200), + ]); + $user = User::factory()->create(); + $workspace = Workspace::factory()->create(['user_id' => $user->id]); + $workspace->members()->attach($user->id, membershipPivot('member')); + $user->update(['current_workspace_id' => $workspace->id]); + $account = SocialAccount::factory()->create(['workspace_id' => $workspace->id, 'platform' => Platform::InstagramFacebook]); + $post = Post::factory()->forAccount($account, ContentType::InstagramReel)->create([ + 'user_id' => $user->id, + 'content' => 'Reel with music', + 'meta' => ['audio' => ['id' => '846373660299207', 'audio_volume' => 40]], + ]); + test()->actingAs($user); + + return $post; +} + +test('a reel whose track was saved with only its id shows the track name and cover', function () { + $post = seedUnsavedChangesReelWithBareTrack(); + + $page = visit(route('app.posts.edit', $post))->resize(1280, 900); + waitForUnsavedChangesCondition($page, "document.querySelector('[data-testid=\"instagram-music-selected-cover\"]')?.getAttribute('src')?.endsWith('#fresh')"); + + $page->assertAttributeContains('@instagram-music-selected-cover', 'src', '#fresh') + ->assertSeeIn('@instagram-music-selected', 'Summer Song') + ->assertSeeIn('@instagram-music-selected', 'The Band') + ->assertSeeIn('@instagram-preview-audio', 'Summer Song · The Band') + ->assertNoJavaScriptErrors(); +}); + +test('track details that arrive after the first click do not count as an unsaved change', function () { + $post = seedUnsavedChangesReelWithBareTrack(); + + $page = visit(route('app.posts.index', ['tab' => 'drafts']))->resize(1280, 900); + waitForUnsavedChangesTestId($page, "post-edit-{$post->id}"); + $page->script(<<<'JS' + (() => { + const open = XMLHttpRequest.prototype.open; + const send = XMLHttpRequest.prototype.send; + XMLHttpRequest.prototype.open = function (method, url, ...rest) { + this.__url = String(url); + return open.call(this, method, url, ...rest); + }; + XMLHttpRequest.prototype.send = function (body) { + if (/\/instagram\/accounts\/[^/]+\/audio\/\d+/.test(this.__url)) { + setTimeout(() => send.call(this, body), 1500); + return; + } + return send.call(this, body); + }; + })() + JS); + $page->click("@post-edit-{$post->id}"); + waitForUnsavedChangesTestId($page, 'composer-editor-column'); + $page->script(<<<'JS' + (() => { + const target = document.querySelector('[data-testid="composer-editor-column"] [contenteditable="true"]') ?? document.querySelector('[data-testid="composer-editor-column"]'); + const options = { bubbles: true, cancelable: true, pointerType: 'mouse', button: 0 }; + target.dispatchEvent(new PointerEvent('pointerdown', options)); + target.dispatchEvent(new PointerEvent('pointerup', options)); + })() + JS); + expect($page->script("Boolean(document.querySelector('[data-testid=\"instagram-music-selected-cover\"]'))"))->toBeFalse(); + waitForUnsavedChangesCondition($page, "document.querySelector('[data-testid=\"instagram-music-selected-cover\"]')?.getAttribute('src')?.endsWith('#fresh')"); + $page->assertAttributeContains('@instagram-music-selected-cover', 'src', '#fresh') + ->click('@composer-close'); + waitForUnsavedChangesCondition($page, "!document.querySelector('[data-testid=\"post-composer-dialog\"]') || document.querySelector('[data-testid=\"unsaved-changes-dialog\"]')"); + + $page->assertMissing('@unsaved-changes-dialog') + ->assertMissing('@post-composer-dialog') + ->assertNoJavaScriptErrors(); +}); + test('after leaving an edited post through an app link the browser back button returns to the post once', function () { $post = seedUnsavedChangesReel(); $page = openUnsavedChangesFromList($post); diff --git a/tests/Feature/Mcp/ToolDocumentationTest.php b/tests/Feature/Mcp/ToolDocumentationTest.php index f94929e931..4e37140314 100644 --- a/tests/Feature/Mcp/ToolDocumentationTest.php +++ b/tests/Feature/Mcp/ToolDocumentationTest.php @@ -21,10 +21,12 @@ use App\Mcp\Tools\Signature\CreateSignatureTool; use App\Mcp\Tools\Signature\ListSignaturesTool; use App\Mcp\Tools\SocialAccount\GeneratePostingScheduleTool; +use App\Mcp\Tools\SocialAccount\GetInstagramAudioTool; use App\Mcp\Tools\SocialAccount\GetTikTokCreatorInfoTool; use App\Mcp\Tools\SocialAccount\ListFreeSlotsTool; use App\Mcp\Tools\SocialAccount\ListSocialAccountsTool; use App\Mcp\Tools\SocialAccount\ReorderQueueTool; +use App\Mcp\Tools\SocialAccount\SearchInstagramAudioTool; use App\Mcp\Tools\SocialAccount\UpdatePostingScheduleTool; use App\Mcp\Tools\Webhook\CreateWebhookTool; use App\Support\PostPlatformMetaRules; @@ -180,3 +182,21 @@ function mcpServerInstructions(): string 'create-posts' => [CreatePostsTool::class], 'update-post' => [UpdatePostTool::class], ]); + +test('the instagram audio tools name the platform that takes music', function (string $tool) { + expect(mcpToolDescription($tool))->toContain('instagram-facebook'); +})->with([ + 'search' => [SearchInstagramAudioTool::class], + 'get' => [GetInstagramAudioTool::class], +]); + +test('the instagram audio search says which fields let the app show the track', function () { + expect(mcpToolDescription(SearchInstagramAudioTool::class))->toContain('title, artist, cover_url and preview_url into meta.audio'); +}); + +test('the meta documentation explains how instagram music and collaborators behave', function () { + expect(PostPlatformMetaRules::documentation()) + ->toContain('platform instagram-facebook') + ->toContain('only after they accept the invite in the Instagram app') + ->toContain('only refused when the post publishes'); +}); From d8cd8de3c6d3adf3ce05ca5c738a663411dc09ce Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Fri, 16 Oct 2026 14:23:44 -0300 Subject: [PATCH 2/5] Look a reel's track up only when the composer opens Picking a track no longer spends an audio lookup, the lookup no longer requires the answer to repeat the id, and the guard helper is named for what it returns. --- .../js/components/posts/editor/InstagramSettings.vue | 8 ++++---- .../composer/useComposerUnsavedChanges.ts | 12 ++++++------ tests/Browser/ComposerChannelSettingsTest.php | 12 +++++++++++- 3 files changed, 21 insertions(+), 11 deletions(-) diff --git a/resources/js/components/posts/editor/InstagramSettings.vue b/resources/js/components/posts/editor/InstagramSettings.vue index 0d03d51fa8..6d01db89c8 100644 --- a/resources/js/components/posts/editor/InstagramSettings.vue +++ b/resources/js/components/posts/editor/InstagramSettings.vue @@ -215,7 +215,7 @@ const refreshSelectedAudio = async (): Promise => { ); const current = selectedAudio.value; - if (!fresh || fresh.id !== audio.id || current?.id !== audio.id) { + if (!fresh || current?.id !== audio.id) { return false; } @@ -240,9 +240,9 @@ const missesTrackDetails = (audio: InstagramAudio | null): boolean => (!audio?.title || !audio.cover_url || !audio.preview_url); watch( - () => [selectedAudio.value?.id, isReel.value] as const, - () => { - if (isReel.value && missesTrackDetails(selectedAudio.value)) { + isReel, + (reel) => { + if (reel && missesTrackDetails(selectedAudio.value)) { void refreshSelectedAudio(); } }, diff --git a/resources/js/composables/composer/useComposerUnsavedChanges.ts b/resources/js/composables/composer/useComposerUnsavedChanges.ts index b6abb933bd..831a3dfd10 100644 --- a/resources/js/composables/composer/useComposerUnsavedChanges.ts +++ b/resources/js/composables/composer/useComposerUnsavedChanges.ts @@ -19,11 +19,11 @@ interface ComposerUnsavedChangesOptions { } /** - * The parts of a picked Instagram track the person chooses: the track and its - * volumes. Its name, cover and preview come with the id and are filled in or - * renewed by the composer on its own, so they never count as a change. + * The meta with only what the person chooses of an Instagram track: the track + * and its volumes. Its name, cover and preview come with the id and are filled + * in or renewed by the composer on its own, so they never count as a change. */ -const chosenTrack = (meta: Record): Record => { +const withoutTrackDetails = (meta: Record): Record => { const audio = meta.audio as InstagramAudio | null | undefined; if (!audio) return meta; @@ -53,7 +53,7 @@ export const useComposerUnsavedChanges = ( ) => { const { composition, scheduleMode } = options; - /** What saving would send, so any change the person makes counts. */ + /** What saving would send, a track's details aside, so any change the person makes counts. */ const snapshot = (): string => { const mode = scheduleMode(); const submission = composition.materialize( @@ -66,7 +66,7 @@ export const useComposerUnsavedChanges = ( ...submission, destinations: submission.destinations.map((destination) => ({ ...destination, - meta: chosenTrack(destination.meta ?? {}), + meta: withoutTrackDetails(destination.meta), })), }, scheduleMode: mode, diff --git a/tests/Browser/ComposerChannelSettingsTest.php b/tests/Browser/ComposerChannelSettingsTest.php index a40a1482aa..c79f8b83d8 100644 --- a/tests/Browser/ComposerChannelSettingsTest.php +++ b/tests/Browser/ComposerChannelSettingsTest.php @@ -793,6 +793,16 @@ function fakeChannelSettingsApis(): void $page = visit(route('app.posts.edit', $post))->resize(1280, 900); waitForChannelSettingsTestId($page, 'instagram-music-add'); + $page->script(<<<'JS' + (() => { + window.__trackLookups = 0; + const open = XMLHttpRequest.prototype.open; + XMLHttpRequest.prototype.open = function (method, url, ...rest) { + if (/\/instagram\/accounts\/[^/]+\/audio\/\d+/.test(String(url))) window.__trackLookups++; + return open.call(this, method, url, ...rest); + }; + })() + JS); $page->click('@instagram-music-add'); waitForChannelSettingsTestId($page, 'instagram-music-track-1234567890'); @@ -826,7 +836,7 @@ function fakeChannelSettingsApis(): void 'cover_url' => null, 'preview_url' => null, 'video_volume' => 30, - ]); + ])->and($page->script('window.__trackLookups'))->toBe(0); }); test('the instagram card counts hashtags down to five and warns past the limit', function () { From b550b80b1d1070969d466237085b9a41dc530580 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Fri, 16 Oct 2026 14:35:02 -0300 Subject: [PATCH 3/5] Share the browser test wait helpers The waits in ComposerUnsavedChangesTest and ComposerChannelSettingsTest go through helpers in tests/Pest.php instead of a polling loop copied per file and inline querySelector conditions. AGENTS.md now requires the shared helpers for every wait; moving the other browser tests is tracked in #435. --- AGENTS.md | 11 +- tests/Browser/ComposerChannelSettingsTest.php | 368 +++++++++--------- tests/Browser/ComposerUnsavedChangesTest.php | 295 +++++++------- tests/Pest.php | 81 ++++ 4 files changed, 409 insertions(+), 346 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 662de79a3d..d2d31fc059 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1025,8 +1025,15 @@ Browser tests live in `tests/Browser` and run on `pestphp/pest-plugin-browser` d - ALWAYS target elements by `data-testid`. NEVER use CSS classes (`.text-red-600`), tag names, or text strings. - `@my-element` resolves to `[data-testid="my-element"]`, so add `data-testid="my-element"` in the Vue component and use `$page->click('@my-element')`. - Bind it for repeated elements: `:data-testid="`connect-${platform.value}`"`. -- Assertions do NOT auto-wait on SPA paint. Wait for the element to mount and lay out first — see the `waitFor*TestId()` helper at the top of `tests/Browser/WelcomeConnectTest.php` and copy the pattern under a file-unique name (these helpers are global functions; a duplicated name collides across test files). -- **Never `sleep()` in a browser test.** The HTTP server that serves the page runs inside the same PHP process (an Amp loop that only ticks while Pest awaits Playwright), so a blocking `sleep()` starves every asset request: the page stays blank, the Vue app never mounts, and screenshots come out empty. Poll from the page with `$page->script(...)` (as the `waitFor*TestId()` helpers do) — that keeps the loop running. +- Assertions do NOT auto-wait on SPA paint (`assertMissing`/`assertVisible` check once; only actions like `click()` wait). **Every wait goes through the shared helpers in `tests/Pest.php`** (owner decision October 2026), never through a helper of your own: + - `waitForTestId($page, 'id')`: the element is mounted and laid out. + - `waitForTestIdGone($page, 'id')`: no element with that test id is left. + - `waitForTestIdState($page, 'id', "element.getAttribute('aria-checked') === 'true'")`: the element exists and the expression about `element` is true (attributes, text, `dataset`, a child). + - `waitForTestIdGoneOrShown($page, gone: 'post-composer-dialog', shown: 'unsaved-changes-dialog')`: whichever happens first, so the assertion after it fails at once instead of waiting out the timeout. + - `waitForPath($page, $path)`: the page is at that path. + - `waitForCondition($page, $js)`: only for what no test id can express (a `role`, `location.search`, a `window.__` spy). + - Never write `document.querySelector('[data-testid=…]')` inside a wait, and never define a per-file `waitFor*` helper. Pest's `Webpage` is `final readonly` and not macroable, which is why these are global functions. Files that still have their own `waitFor*` helpers predate this rule; move them to the shared helpers when touching them, and the sweep is tracked in #435. If a wait needs something the helpers cannot express, add a helper to `tests/Pest.php` instead of inlining it. +- **Never `sleep()` in a browser test.** The HTTP server that serves the page runs inside the same PHP process (an Amp loop that only ticks while Pest awaits Playwright), so a blocking `sleep()` starves every asset request: the page stays blank, the Vue app never mounts, and screenshots come out empty. The helpers above poll from the page with `$page->script(...)`, which keeps the loop running. - `BrowserTestCase` sets `$fakesVite = false` on purpose: these tests load real built assets, so faking Vite blanks the app. - End page assertions with `->assertNoJavaScriptErrors()`. - Each `visit()` opens a new browser context: `localStorage`, cookies and history are not shared with the previous visit. diff --git a/tests/Browser/ComposerChannelSettingsTest.php b/tests/Browser/ComposerChannelSettingsTest.php index c79f8b83d8..1765d87770 100644 --- a/tests/Browser/ComposerChannelSettingsTest.php +++ b/tests/Browser/ComposerChannelSettingsTest.php @@ -47,26 +47,6 @@ function seedChannelSettingsPost(Platform $platform, ContentType $contentType, a return $post; } -function waitForChannelSettingsCondition(mixed $page, string $condition): void -{ - $page->script(<< { - for (let i = 0; i < 300; i++) { - if ({$condition}) return; - await new Promise((resolve) => setTimeout(resolve, 50)); - } - })(); - JS); -} - -function waitForChannelSettingsTestId(mixed $page, string $testId): void -{ - waitForChannelSettingsCondition( - $page, - "document.querySelector('[data-testid=\"{$testId}\"]')?.getBoundingClientRect().height > 0", - ); -} - function channelSettingsEventDay(int $daysAfterStart): string { return now()->addYear()->startOfDay()->addDays($daysAfterStart)->toDateString(); @@ -74,7 +54,7 @@ function channelSettingsEventDay(int $daysAfterStart): string function waitForChannelSettingsClosedMenu(mixed $page): void { - waitForChannelSettingsCondition($page, "!document.querySelector('[role=\"listbox\"]')"); + waitForCondition($page, "!document.querySelector('[role=\"listbox\"]')"); } function fakeChannelSettingsApis(): void @@ -106,7 +86,7 @@ function fakeChannelSettingsApis(): void $post = seedChannelSettingsPost($platform, $contentType, $media); $page = visit(route('app.posts.edit', $post))->resize(1280, 900); - waitForChannelSettingsTestId($page, 'channel-settings-rows'); + waitForTestId($page, 'channel-settings-rows'); $layout = $page->script(<<<'JS' (() => { @@ -164,7 +144,7 @@ function fakeChannelSettingsApis(): void $id = $post->social_account_id; $page = visit(route('app.posts.edit', $post))->resize(1280, 900); - waitForChannelSettingsTestId($page, "composer-type-{$id}-instagram_feed"); + waitForTestId($page, "composer-type-{$id}-instagram_feed"); $page->assertAttribute("@composer-type-{$id}-instagram_feed", 'aria-checked', 'true') ->assertAttribute("@composer-type-{$id}-instagram_reel", 'aria-checked', 'false') @@ -180,7 +160,7 @@ function fakeChannelSettingsApis(): void JS))->toBeTrue(); $page->click("@composer-type-{$id}-instagram_reel"); - waitForChannelSettingsCondition($page, "document.querySelector('[data-testid=\"composer-type-{$id}-instagram_reel\"]')?.getAttribute('aria-checked') === 'true'"); + waitForTestIdState($page, "composer-type-{$id}-instagram_reel", "element.getAttribute('aria-checked') === 'true'"); $page->assertAttribute("@composer-type-{$id}-instagram_reel", 'aria-checked', 'true') ->assertNoJavaScriptErrors(); @@ -196,7 +176,7 @@ function fakeChannelSettingsApis(): void $post->update(['media' => $images->map(fn (Media $media): array => MediaItem::fromMedia($media)->toArray())->all()]); $page = visit(route('app.posts.edit', $post))->resize(1280, 900); - waitForChannelSettingsTestId($page, 'pinterest-board-trigger'); + waitForTestId($page, 'pinterest-board-trigger'); $page->assertMissing("@composer-type-{$id}") ->assertNoJavaScriptErrors() @@ -211,27 +191,27 @@ function fakeChannelSettingsApis(): void $post = seedChannelSettingsPost(Platform::Pinterest, ContentType::PinterestPin); $page = visit(route('app.posts.edit', $post))->resize(1280, 900); - waitForChannelSettingsTestId($page, 'pinterest-board-trigger'); + waitForTestId($page, 'pinterest-board-trigger'); $page->click('@pinterest-board-trigger'); - waitForChannelSettingsTestId($page, 'pinterest-board-option-board_1'); + waitForTestId($page, 'pinterest-board-option-board_1'); $page->assertSeeIn('@pinterest-board-picker', '@trypostit') ->assertVisible('@pinterest-board-option-board_2'); expect($page->script("!!document.querySelector('[data-testid=\"pinterest-board-option-board_1\"] img')"))->toBeTrue(); $page->fill('@pinterest-board-search', 'DÍS'); - waitForChannelSettingsCondition($page, "!document.querySelector('[data-testid=\"pinterest-board-option-board_2\"]')"); + waitForTestIdGone($page, 'pinterest-board-option-board_2'); $page->assertMissing('@pinterest-board-option-board_2') ->assertVisible('@pinterest-board-option-board_1') ->fill('@pinterest-board-search', ''); $page->click('@pinterest-boards-refresh'); - waitForChannelSettingsTestId($page, 'pinterest-board-option-board_3'); + waitForTestId($page, 'pinterest-board-option-board_3'); $page->assertSeeIn('@pinterest-board-option-board_3', 'Universal'); $page->fill('@pinterest-board-new-name', 'Recipes') ->click('@pinterest-board-create'); - waitForChannelSettingsTestId($page, 'pinterest-board-selected'); + waitForTestId($page, 'pinterest-board-selected'); $page->assertSeeIn('@pinterest-board-selected', 'Recipes') ->assertMissing('@pinterest-board-picker') ->assertNoJavaScriptErrors(); @@ -241,15 +221,15 @@ function fakeChannelSettingsApis(): void && $request->data() === ['name' => 'Recipes']); $page->click('@pinterest-board-trigger'); - waitForChannelSettingsTestId($page, 'pinterest-board-new-name'); + waitForTestId($page, 'pinterest-board-new-name'); expect($page->script("document.querySelector('[data-testid=\"pinterest-board-new-name\"]').value"))->toBe(''); $page->click('@pinterest-boards-refresh'); - waitForChannelSettingsCondition($page, "!document.querySelector('[data-testid=\"pinterest-board-option-board_new\"]')"); + waitForTestIdGone($page, 'pinterest-board-option-board_new'); $page->assertMissing('@pinterest-board-option-board_new') ->assertVisible('@pinterest-board-option-board_3'); $page->click('@pinterest-board-option-board_3'); - waitForChannelSettingsCondition($page, "!document.querySelector('[data-testid=\"pinterest-board-picker\"]')"); + waitForTestIdGone($page, 'pinterest-board-picker'); $page->click('@composer-save-draft')->assertMissing('@post-composer-dialog'); expect(data_get($post->fresh()->meta, 'board_id'))->toBe('board_3'); @@ -260,7 +240,7 @@ function fakeChannelSettingsApis(): void $post = seedChannelSettingsPost(Platform::GoogleBusiness, ContentType::GoogleBusinessPost); $page = visit(route('app.posts.edit', $post))->resize(1280, 900); - waitForChannelSettingsTestId($page, 'google-business-topic-STANDARD'); + waitForTestId($page, 'google-business-topic-STANDARD'); $fields = <<<'JS' [...document.querySelectorAll('[data-testid="composer-customization"] [data-testid^="google-business-"]')] .map((node) => node.dataset.testid) @@ -274,29 +254,29 @@ function fakeChannelSettingsApis(): void ->and($page->script($fields))->toBe(['google-business-cta']); $page->click('@google-business-topic-OFFER'); - waitForChannelSettingsTestId($page, 'google-business-add-details'); + waitForTestId($page, 'google-business-add-details'); expect($page->script($fields))->toBe(['google-business-title', 'google-business-start', 'google-business-end', 'google-business-add-details']) ->and($page->script("document.querySelector('[data-testid=\"google-business-add-details\"]').getAttribute('aria-checked')"))->toBe('false'); $page->click('@google-business-topic-STANDARD'); - waitForChannelSettingsTestId($page, 'google-business-cta'); + waitForTestId($page, 'google-business-cta'); $page->click('@google-business-cta'); - waitForChannelSettingsTestId($page, 'google-business-cta-option-BOOK'); + waitForTestId($page, 'google-business-cta-option-BOOK'); $page->click('@google-business-cta-option-BOOK'); - waitForChannelSettingsTestId($page, 'google-business-cta-url'); + waitForTestId($page, 'google-business-cta-url'); $page->fill('@google-business-cta-url', 'https://trypost.it/book') ->click('@google-business-topic-OFFER'); - waitForChannelSettingsTestId($page, 'google-business-redeem'); + waitForTestId($page, 'google-business-redeem'); expect($page->script($fields))->toBe(['google-business-title', 'google-business-start', 'google-business-end', 'google-business-add-details', 'google-business-coupon', 'google-business-redeem', 'google-business-terms']); $page->assertValue('@google-business-redeem', 'https://trypost.it/book') ->click('@google-business-add-details'); - waitForChannelSettingsCondition($page, "!document.querySelector('[data-testid=\"google-business-coupon\"]')"); + waitForTestIdGone($page, 'google-business-coupon'); $page->assertMissing('@google-business-redeem'); $page->click('@google-business-topic-EVENT'); - waitForChannelSettingsTestId($page, 'google-business-add-time'); + waitForTestId($page, 'google-business-add-time'); expect($page->script($fields))->toBe(['google-business-title', 'google-business-add-time', 'google-business-start', 'google-business-end', 'google-business-cta']); @@ -320,14 +300,14 @@ function fakeChannelSettingsApis(): void $post = seedChannelSettingsPost(Platform::GoogleBusiness, ContentType::GoogleBusinessPost); $page = visit(route('app.posts.edit', $post))->resize(1280, 900); - waitForChannelSettingsTestId($page, 'google-business-cta'); + waitForTestId($page, 'google-business-cta'); $page->click('@google-business-cta'); - waitForChannelSettingsTestId($page, 'google-business-cta-option-BOOK'); + waitForTestId($page, 'google-business-cta-option-BOOK'); $page->click('@google-business-cta-option-BOOK'); - waitForChannelSettingsTestId($page, 'google-business-cta-url'); + waitForTestId($page, 'google-business-cta-url'); $page->fill('@google-business-cta-url', 'not a link') ->click('@composer-save-draft'); - waitForChannelSettingsCondition($page, "document.querySelector('[data-testid=\"google-business-cta-url\"]')?.getAttribute('aria-invalid') === 'true'"); + waitForTestIdState($page, 'google-business-cta-url', "element.getAttribute('aria-invalid') === 'true'"); expect($page->script("document.querySelector('[data-testid=\"google-business-cta-url\"]').getAttribute('aria-invalid')"))->toBe('true') ->and($page->script("document.querySelector('[data-testid=\"google-business-cta-url\"]').closest('[data-testid=\"channel-settings-row\"]').textContent")) @@ -343,11 +323,11 @@ function fakeChannelSettingsApis(): void $post->update(['meta' => ['topic_type' => 'EVENT', 'event' => ['title' => 'Launch night', 'start_date' => channelSettingsEventDay(0), 'end_date' => channelSettingsEventDay(2)]]]); $page = visit(route('app.posts.edit', $post))->resize(1280, 900); - waitForChannelSettingsTestId($page, 'google-business-add-time'); + waitForTestId($page, 'google-business-add-time'); $page->assertMissing('@google-business-start-time') ->assertMissing('@google-business-end-time') ->click('@google-business-add-time'); - waitForChannelSettingsTestId($page, 'google-business-start-time'); + waitForTestId($page, 'google-business-start-time'); $layout = $page->script(<<<'JS' (() => { @@ -383,7 +363,7 @@ function fakeChannelSettingsApis(): void $post = seedChannelSettingsPost(Platform::YouTube, ContentType::YouTubeShort); $page = visit(route('app.posts.edit', $post))->resize(1280, 900); - waitForChannelSettingsTestId($page, 'youtube-title'); + waitForTestId($page, 'youtube-title'); $order = ['youtube-title', 'youtube-category', 'youtube-privacy', 'youtube-license', 'youtube-notify-subscribers', 'youtube-embeddable', 'youtube-made-for-kids', 'youtube-ai-generated']; @@ -404,15 +384,15 @@ function fakeChannelSettingsApis(): void ->assertValue('@youtube-title', 'Channel settings') ->fill('@youtube-title', 'Explicit title') ->click('@youtube-category'); - waitForChannelSettingsTestId($page, 'youtube-category-27'); + waitForTestId($page, 'youtube-category-27'); $page->click('@youtube-category-27'); waitForChannelSettingsClosedMenu($page); $page->click('@youtube-privacy'); - waitForChannelSettingsTestId($page, 'youtube-privacy-unlisted'); + waitForTestId($page, 'youtube-privacy-unlisted'); $page->click('@youtube-privacy-unlisted'); waitForChannelSettingsClosedMenu($page); $page->click('@youtube-license'); - waitForChannelSettingsTestId($page, 'youtube-license-creativeCommon'); + waitForTestId($page, 'youtube-license-creativeCommon'); $page->click('@youtube-license-creativeCommon'); waitForChannelSettingsClosedMenu($page); $page->click('@youtube-notify-subscribers') @@ -439,7 +419,7 @@ function fakeChannelSettingsApis(): void $post = seedChannelSettingsPost(Platform::TikTok, ContentType::TikTokVideo); $page = visit(route('app.posts.edit', $post))->resize(1280, 900); - waitForChannelSettingsTestId($page, 'tiktok-ai-generated'); + waitForTestId($page, 'tiktok-ai-generated'); expect($page->script(<<<'JS' (() => { @@ -467,16 +447,16 @@ function fakeChannelSettingsApis(): void expect($today->format('Y-m-d'))->not->toBe(now('UTC')->format('Y-m-d')); $page = visit(route('app.posts.edit', $post))->withTimezone('UTC')->resize(1280, 900); - waitForChannelSettingsTestId($page, 'google-business-topic-EVENT'); + waitForTestId($page, 'google-business-topic-EVENT'); $page->click('@google-business-topic-EVENT'); - waitForChannelSettingsTestId($page, 'google-business-add-time'); + waitForTestId($page, 'google-business-add-time'); $page->fill('@google-business-title', 'Launch night') ->click('@google-business-add-time'); - waitForChannelSettingsTestId($page, 'google-business-start-time'); + waitForTestId($page, 'google-business-start-time'); $page->fill('@google-business-start-time', '18:30') ->fill('@google-business-end-time', '21:00') ->click('@google-business-topic-OFFER'); - waitForChannelSettingsTestId($page, 'google-business-add-details'); + waitForTestId($page, 'google-business-add-details'); $page->assertMissing('@google-business-start-time') ->assertNoJavaScriptErrors() @@ -500,7 +480,7 @@ function fakeChannelSettingsApis(): void $id = $post->social_account_id; $page = visit(route('app.posts.edit', $post))->resize(1280, 900); - waitForChannelSettingsTestId($page, 'instagram-share-to-feed'); + waitForTestId($page, 'instagram-share-to-feed'); expect($page->script("document.querySelector('[data-testid=\"instagram-share-to-feed\"]').getAttribute('aria-checked')"))->toBe('true') ->and($page->script(<<<'JS' @@ -523,13 +503,13 @@ function fakeChannelSettingsApis(): void $page->click('@instagram-share-to-feed') ->click('@instagram-ai-generated') ->click("@composer-type-{$id}-instagram_story"); - waitForChannelSettingsCondition($page, "!document.querySelector('[data-testid=\"instagram-share-to-feed\"]')"); - waitForChannelSettingsTestId($page, 'instagram-ai-generated'); + waitForTestIdGone($page, 'instagram-share-to-feed'); + waitForTestId($page, 'instagram-ai-generated'); $page->click("@composer-type-{$id}-instagram_feed"); - waitForChannelSettingsTestId($page, 'instagram-ai-generated'); + waitForTestId($page, 'instagram-ai-generated'); $page->assertMissing('@instagram-share-to-feed') ->click("@composer-type-{$id}-instagram_reel"); - waitForChannelSettingsTestId($page, 'instagram-share-to-feed'); + waitForTestId($page, 'instagram-share-to-feed'); $page->assertNoJavaScriptErrors(); $page->click('@composer-save-draft')->assertMissing('@post-composer-dialog'); @@ -545,39 +525,39 @@ function fakeChannelSettingsApis(): void $post = seedChannelSettingsPost(Platform::Instagram, ContentType::InstagramReel); $page = visit(route('app.posts.edit', $post))->resize(1280, 900); - waitForChannelSettingsTestId($page, 'instagram-music-add'); + waitForTestId($page, 'instagram-music-add'); $page->click('@instagram-music-add'); - waitForChannelSettingsTestId($page, 'instagram-music-link-facebook'); + waitForTestId($page, 'instagram-music-link-facebook'); $page->assertPresent('@instagram-music-save-and-link') ->click('@instagram-music-maybe-later'); - waitForChannelSettingsCondition($page, "!document.querySelector('[data-testid=\"instagram-music-link-facebook\"]')"); + waitForTestIdGone($page, 'instagram-music-link-facebook'); $page->type('@instagram-collaborator-input', '@alice') ->keys('@instagram-collaborator-input', 'Enter'); - waitForChannelSettingsTestId($page, 'instagram-collaborators-notice'); + waitForTestId($page, 'instagram-collaborators-notice'); $page->assertPresent('@instagram-collaborator-alice') ->assertPresent('@instagram-trial-reel-unavailable') ->click('@instagram-collaborators-notice-dismiss'); - waitForChannelSettingsCondition($page, "!document.querySelector('[data-testid=\"instagram-collaborators-notice\"]')"); + waitForTestIdGone($page, 'instagram-collaborators-notice'); $page->type('@instagram-collaborator-input', 'bob') ->click('@instagram-collaborator-add'); - waitForChannelSettingsTestId($page, 'instagram-collaborator-bob'); + waitForTestId($page, 'instagram-collaborator-bob'); $page->click('@instagram-collaborator-remove-alice') ->click('@instagram-collaborator-remove-bob'); - waitForChannelSettingsTestId($page, 'instagram-trial-reel-MANUAL'); + waitForTestId($page, 'instagram-trial-reel-MANUAL'); $page->click('@instagram-trial-reel-MANUAL'); - waitForChannelSettingsTestId($page, 'instagram-trial-reel-check'); + waitForTestId($page, 'instagram-trial-reel-check'); $page->assertVisible('@instagram-trial-reel-illustration') ->click('@instagram-trial-reel-check-no'); - waitForChannelSettingsCondition($page, "!document.querySelector('[data-testid=\"instagram-trial-reel-check\"]')"); + waitForTestIdGone($page, 'instagram-trial-reel-check'); $page->assertAttribute('@instagram-trial-reel-off', 'aria-checked', 'true') ->click('@instagram-trial-reel-MANUAL'); - waitForChannelSettingsTestId($page, 'instagram-trial-reel-check'); + waitForTestId($page, 'instagram-trial-reel-check'); $page->click('@instagram-trial-reel-check-yes'); - waitForChannelSettingsCondition($page, "document.querySelector('[data-testid=\"instagram-trial-reel-MANUAL\"]')?.getAttribute('aria-checked') === 'true'"); + waitForTestIdState($page, 'instagram-trial-reel-MANUAL', "element.getAttribute('aria-checked') === 'true'"); $page->assertNoJavaScriptErrors(); $page->click('@composer-save-draft')->assertMissing('@post-composer-dialog'); @@ -593,12 +573,12 @@ function fakeChannelSettingsApis(): void $post->update(['status' => Status::Scheduled, 'scheduled_at' => now()->addDay()]); $page = visit(route('app.posts.edit', $post))->resize(1280, 900); - waitForChannelSettingsTestId($page, 'instagram-music-add'); + waitForTestId($page, 'instagram-music-add'); $page->click('@instagram-music-add'); - waitForChannelSettingsTestId($page, 'instagram-music-save-and-link'); + waitForTestId($page, 'instagram-music-save-and-link'); $page->click('@instagram-music-save-and-link'); - waitForChannelSettingsTestId($page, 'instagram-facebook-requirements'); + waitForTestId($page, 'instagram-facebook-requirements'); $page->assertMissing('@post-composer-dialog') ->assertPresent('@instagram-facebook-requirements-connect') @@ -612,7 +592,7 @@ function fakeChannelSettingsApis(): void }); JS); $page->click('@instagram-facebook-requirements-connect'); - waitForChannelSettingsCondition($page, 'window.__connectUrl !== undefined'); + waitForCondition($page, 'window.__connectUrl !== undefined'); expect($page->script('window.__connectUrl'))->toContain("reconnect={$post->social_account_id}"); }); @@ -624,18 +604,18 @@ function fakeChannelSettingsApis(): void $post->update(['status' => Status::Scheduled, 'scheduled_at' => now()->addDay()]); $page = visit(route('app.posts.edit', $post))->resize(1280, 900); - waitForChannelSettingsTestId($page, 'composer-schedule-trigger'); + waitForTestId($page, 'composer-schedule-trigger'); $page->click('@composer-schedule-trigger'); - waitForChannelSettingsTestId($page, 'composer-schedule-more-actions'); + waitForTestId($page, 'composer-schedule-more-actions'); $page->click('@composer-schedule-more-actions'); - waitForChannelSettingsTestId($page, 'composer-schedule-now'); + waitForTestId($page, 'composer-schedule-now'); $page->click('@composer-schedule-now'); - waitForChannelSettingsCondition($page, "document.querySelector('[data-testid=\"composer-submit\"]')?.dataset.scheduleMode === 'now'"); + waitForTestIdState($page, 'composer-submit', "element.dataset.scheduleMode === 'now'"); $page->click('@instagram-music-add'); - waitForChannelSettingsTestId($page, 'instagram-music-save-and-link'); + waitForTestId($page, 'instagram-music-save-and-link'); $page->click('@instagram-music-save-and-link'); - waitForChannelSettingsTestId($page, 'instagram-facebook-requirements'); + waitForTestId($page, 'instagram-facebook-requirements'); $page->assertNoJavaScriptErrors(); expect($post->fresh()->status)->toBe(Status::Draft); @@ -647,12 +627,12 @@ function fakeChannelSettingsApis(): void $post->update(['status' => Status::Scheduled, 'scheduled_at' => now()->addDay()]); $page = visit(route('app.posts.edit', $post))->resize(1280, 900); - waitForChannelSettingsTestId($page, 'instagram-music-add'); + waitForTestId($page, 'instagram-music-add'); $page->click('@instagram-music-add'); - waitForChannelSettingsTestId($page, 'instagram-music-save-and-link'); + waitForTestId($page, 'instagram-music-save-and-link'); $page->click('@instagram-music-save-and-link'); - waitForChannelSettingsTestId($page, 'instagram-music-save-and-link-blocked'); + waitForTestId($page, 'instagram-music-save-and-link-blocked'); $page->assertSee(__('posts.form.instagram.music.save_and_link_blocked')) ->assertVisible('@post-composer-dialog') @@ -669,11 +649,11 @@ function fakeChannelSettingsApis(): void $post->update(['status' => Status::PendingApproval, 'scheduled_at' => now()->addDay()]); $page = visit(route('app.posts.edit', $post))->resize(1280, 900); - waitForChannelSettingsTestId($page, 'instagram-music-add'); + waitForTestId($page, 'instagram-music-add'); $page->click('@instagram-music-add'); - waitForChannelSettingsTestId($page, 'instagram-music-save-and-link'); + waitForTestId($page, 'instagram-music-save-and-link'); $page->click('@instagram-music-save-and-link'); - waitForChannelSettingsTestId($page, 'instagram-facebook-requirements'); + waitForTestId($page, 'instagram-facebook-requirements'); $page->assertNoJavaScriptErrors(); expect($post->fresh()->status)->toBe(Status::PendingApproval); @@ -686,11 +666,11 @@ function fakeChannelSettingsApis(): void $post->update(['status' => Status::PendingApproval, 'scheduled_at' => now()->addDay()]); $page = visit(route('app.posts.edit', $post))->resize(1280, 900); - waitForChannelSettingsTestId($page, 'instagram-music-add'); + waitForTestId($page, 'instagram-music-add'); $page->click('@instagram-music-add'); - waitForChannelSettingsTestId($page, 'instagram-music-save-and-link'); + waitForTestId($page, 'instagram-music-save-and-link'); $page->click('@instagram-music-save-and-link'); - waitForChannelSettingsTestId($page, 'instagram-music-save-and-link-pending'); + waitForTestId($page, 'instagram-music-save-and-link-pending'); $page->assertSee(__('posts.form.instagram.music.save_and_link_pending')) ->assertVisible('@post-composer-dialog') @@ -703,12 +683,12 @@ function fakeChannelSettingsApis(): void $post = seedChannelSettingsPost(Platform::Instagram, ContentType::InstagramReel); $page = visit(route('app.posts.edit', $post))->resize(1280, 900); - waitForChannelSettingsTestId($page, 'instagram-music-add'); + waitForTestId($page, 'instagram-music-add'); $page->click('@instagram-music-add'); - waitForChannelSettingsTestId($page, 'instagram-music-save-and-link'); + waitForTestId($page, 'instagram-music-save-and-link'); $page->click('@instagram-music-save-and-link'); - waitForChannelSettingsTestId($page, 'instagram-facebook-requirements'); + waitForTestId($page, 'instagram-facebook-requirements'); $page->assertMissing('@post-composer-dialog')->assertNoJavaScriptErrors(); expect($post->fresh()->status)->toBe(Status::Draft); @@ -719,18 +699,19 @@ function fakeChannelSettingsApis(): void $post = seedChannelSettingsPost(Platform::Instagram, ContentType::InstagramReel); $page = visit(route('app.posts.edit', $post))->resize(1280, 900); - waitForChannelSettingsTestId($page, 'instagram-collaborator-input'); + waitForTestId($page, 'instagram-collaborator-input'); $page->type('@instagram-collaborator-input', 'good_user') ->keys('@instagram-collaborator-input', 'Enter'); - waitForChannelSettingsTestId($page, 'instagram-collaborators-notice'); + waitForTestId($page, 'instagram-collaborators-notice'); $page->click('@instagram-collaborators-notice-dismiss') ->type('@instagram-collaborator-input', 'bad-name!') ->click('@instagram-collaborator-add'); - waitForChannelSettingsTestId($page, 'instagram-collaborator-bad-name!'); + waitForTestId($page, 'instagram-collaborator-bad-name!'); $page->click('@composer-save-draft'); - waitForChannelSettingsCondition($page, "document.querySelector('[data-testid=\"instagram-collaborators\"]')?.closest('[data-testid=\"channel-settings-row\"]')?.textContent.includes(".json_encode(__('posts.form.instagram.collaborators.invalid')).')'); + $invalid = json_encode(__('posts.form.instagram.collaborators.invalid')); + waitForTestIdState($page, 'instagram-collaborators', "element.closest('[data-testid=\"channel-settings-row\"]')?.textContent.includes({$invalid})"); $page->assertSee(__('posts.form.instagram.collaborators.invalid'))->assertNoJavaScriptErrors(); expect(data_get($post->fresh()->meta, 'collaborators'))->toBeNull(); @@ -754,8 +735,8 @@ function fakeChannelSettingsApis(): void ]]]); $page = visit(route('app.posts.edit', $post))->resize(1280, 900); - waitForChannelSettingsTestId($page, 'instagram-music-selected'); - waitForChannelSettingsCondition($page, "document.querySelector('[data-testid=\"instagram-music-selected-cover\"]')?.getAttribute('src')?.startsWith('data:image/gif')"); + waitForTestId($page, 'instagram-music-selected'); + waitForTestIdState($page, 'instagram-music-selected-cover', "element.getAttribute('src')?.startsWith('data:image/gif')"); expect($page->script("document.querySelector('[data-testid=\"instagram-music-selected-cover\"]')?.getAttribute('src')"))->toBe($freshCover); $page->assertNoJavaScriptErrors(); @@ -773,8 +754,8 @@ function fakeChannelSettingsApis(): void ]]]); $page = visit(route('app.posts.edit', $post))->resize(1280, 900); - waitForChannelSettingsTestId($page, 'instagram-music-selected'); - waitForChannelSettingsCondition($page, "!document.querySelector('[data-testid=\"instagram-music-selected-cover\"]')"); + waitForTestId($page, 'instagram-music-selected'); + waitForTestIdGone($page, 'instagram-music-selected-cover'); $page->assertMissing('@instagram-music-selected-cover') ->assertSeeIn('@instagram-music-selected', 'Summer Song'); @@ -792,7 +773,7 @@ function fakeChannelSettingsApis(): void $post = seedChannelSettingsPost(Platform::InstagramFacebook, ContentType::InstagramReel); $page = visit(route('app.posts.edit', $post))->resize(1280, 900); - waitForChannelSettingsTestId($page, 'instagram-music-add'); + waitForTestId($page, 'instagram-music-add'); $page->script(<<<'JS' (() => { window.__trackLookups = 0; @@ -805,16 +786,16 @@ function fakeChannelSettingsApis(): void JS); $page->click('@instagram-music-add'); - waitForChannelSettingsTestId($page, 'instagram-music-track-1234567890'); + waitForTestId($page, 'instagram-music-track-1234567890'); $page->click('@instagram-music-track-1234567890'); - waitForChannelSettingsTestId($page, 'instagram-music-selected'); + waitForTestId($page, 'instagram-music-selected'); $page->assertSeeIn('@instagram-music-selected', 'Summer Song') ->assertSeeIn('@instagram-preview-audio', 'Summer Song') ->assertPresent('@instagram-music-part-hint') ->assertNoJavaScriptErrors(); $page->click('@instagram-music-volume'); - waitForChannelSettingsTestId($page, 'instagram-music-video_volume'); + waitForTestId($page, 'instagram-music-video_volume'); $page->script(<<<'JS' (() => { const slider = document.querySelector('[data-testid="instagram-music-video_volume"]'); @@ -822,10 +803,10 @@ function fakeChannelSettingsApis(): void slider.dispatchEvent(new Event('input', { bubbles: true })); })() JS); - waitForChannelSettingsCondition($page, "document.querySelector('[data-testid=\"instagram-music-volume-panel\"]')?.textContent.includes('30%')"); + waitForTestIdState($page, 'instagram-music-volume-panel', "element.textContent.includes('30%')"); $page->assertSeeIn('@instagram-music-volume-panel', '30%') ->keys('@instagram-music-video_volume', 'Escape'); - waitForChannelSettingsCondition($page, "!document.querySelector('[data-testid=\"instagram-music-volume-panel\"]')"); + waitForTestIdGone($page, 'instagram-music-volume-panel'); $page->click('@composer-save-draft')->assertMissing('@post-composer-dialog'); @@ -845,7 +826,7 @@ function fakeChannelSettingsApis(): void $id = $post->social_account_id; $page = visit(route('app.posts.edit', $post))->resize(1280, 900); - waitForChannelSettingsTestId($page, "composer-hashtags-remaining-{$id}"); + waitForTestId($page, "composer-hashtags-remaining-{$id}"); expect($page->script(<< { @@ -862,12 +843,12 @@ function fakeChannelSettingsApis(): void ->assertSeeIn("@composer-hashtags-remaining-{$id}", '2') ->assertMissing("@composer-hashtag-warning-{$id}") ->fill("@composer-caption-{$id}", 'Launch #a #b #c #d #e #f'); - waitForChannelSettingsTestId($page, "composer-hashtag-warning-{$id}"); + waitForTestId($page, "composer-hashtag-warning-{$id}"); $page->assertMissing("@composer-hashtags-remaining-{$id}") ->assertSeeIn("@composer-hashtag-warning-{$id}", 'Instagram only allows 5 hashtags per post.') ->fill("@composer-caption-{$id}", 'Launch #a #b #c #d #e'); - waitForChannelSettingsCondition($page, "!document.querySelector('[data-testid=\"composer-hashtag-warning-{$id}\"]')"); + waitForTestIdGone($page, "composer-hashtag-warning-{$id}"); $page->assertSeeIn("@composer-hashtags-remaining-{$id}", '0') ->assertNoJavaScriptErrors(); @@ -880,7 +861,7 @@ function fakeChannelSettingsApis(): void $limit = Platform::Mastodon->maxContentLength(); $page = visit(route('app.posts.edit', $post))->resize(1280, 900); - waitForChannelSettingsTestId($page, 'mastodon-content-warning'); + waitForTestId($page, 'mastodon-content-warning'); $page->assertSeeIn("@composer-char-count-{$id}", (string) ($limit - mb_strlen('Channel settings'))) ->fill('@mastodon-content-warning', 'Spoilers') @@ -899,19 +880,19 @@ function fakeChannelSettingsApis(): void $id = $post->social_account_id; $page = visit(route('app.posts.edit', $post))->resize(1280, 900); - waitForChannelSettingsTestId($page, 'threads-topic-tag'); + waitForTestId($page, 'threads-topic-tag'); $page->fill('@threads-topic-tag', '#laravel') ->assertPresent("@composer-{$id}-dropzone") ->click("@composer-type-{$id}-threads_ghost_post"); - waitForChannelSettingsCondition($page, "!document.querySelector('[data-testid=\"threads-topic-tag\"]')"); + waitForTestIdGone($page, 'threads-topic-tag'); $page->assertMissing("@composer-{$id}-dropzone") ->assertMissing("@composer-{$id}-media-source-menu") ->assertVisible("@composer-{$id}-media-source-disabled") ->hover("@composer-{$id}-media-source-disabled"); - waitForChannelSettingsCondition($page, 'document.body.innerText.includes('.json_encode(__('posts.composer.media_not_supported')).')'); + waitForCondition($page, 'document.body.innerText.includes('.json_encode(__('posts.composer.media_not_supported')).')'); $page->click("@composer-type-{$id}-threads_post"); - waitForChannelSettingsTestId($page, 'threads-topic-tag'); + waitForTestId($page, 'threads-topic-tag'); $page->assertVisible("@composer-{$id}-media-source-menu") ->assertMissing("@composer-{$id}-media-source-disabled") ->assertNoJavaScriptErrors(); @@ -933,12 +914,12 @@ function fakeChannelSettingsApis(): void $post->update(['content' => "Read {$url}"]); $page = visit(route('app.posts.edit', $post))->resize(1280, 900); - waitForChannelSettingsTestId($page, "composer-link-card-{$id}"); + waitForTestId($page, "composer-link-card-{$id}"); expect($page->script("document.querySelector('[data-testid=\"{$ghost}\"]').disabled"))->toBeFalse(); $page->click("@{$ghost}"); - waitForChannelSettingsCondition($page, "!document.querySelector('[data-testid=\"composer-link-card-{$id}\"]')"); + waitForTestIdGone($page, "composer-link-card-{$id}"); $page->assertMissing("@composer-text-only-warning-{$id}") ->assertNoJavaScriptErrors(); @@ -954,24 +935,25 @@ function fakeChannelSettingsApis(): void $limit = Platform::Bluesky->maxContentLength(); $page = visit(route('app.posts.edit', $post))->resize(1280, 900); - waitForChannelSettingsTestId($page, 'thread-start'); + waitForTestId($page, 'thread-start'); expect($page->script("!!document.querySelector('[data-testid=\"composer-{$id}-toolbar\"] [data-testid=\"thread-start\"]')"))->toBeTrue(); $page->click('@thread-start'); - waitForChannelSettingsTestId($page, 'thread-reply-0'); + waitForTestId($page, 'thread-reply-0'); $page->assertVisible("@composer-{$id}-caption-collapsed") ->fill('@thread-reply-0', 'Second post') ->assertSeeIn("@composer-char-count-{$id}", (string) ($limit - mb_strlen('Second post'))) ->click('@thread-add-reply'); - waitForChannelSettingsTestId($page, 'thread-reply-1'); + waitForTestId($page, 'thread-reply-1'); $page->fill('@thread-reply-1', 'Third post') ->assertVisible('@thread-reply-collapsed-0') ->assertSeeIn('@preview-thread-reply-1', 'Third post') ->click('@thread-reply-remove-0'); - waitForChannelSettingsCondition($page, "!document.querySelector('[data-testid=\"thread-reply-1\"], [data-testid=\"thread-reply-collapsed-1\"]')"); + waitForTestIdGone($page, 'thread-reply-1'); + waitForTestIdGone($page, 'thread-reply-collapsed-1'); $page->click("@composer-{$id}-caption-collapsed"); - waitForChannelSettingsTestId($page, "composer-caption-{$id}"); + waitForTestId($page, "composer-caption-{$id}"); $page->assertNoJavaScriptErrors(); $page->click('@composer-save-draft')->assertMissing('@post-composer-dialog'); @@ -984,11 +966,11 @@ function fakeChannelSettingsApis(): void $post = seedChannelSettingsPost(Platform::Bluesky, ContentType::BlueskyPost); $page = visit(route('app.posts.edit', $post))->resize(1280, 900); - waitForChannelSettingsTestId($page, 'thread-start'); + waitForTestId($page, 'thread-start'); $page->click('@thread-start'); - waitForChannelSettingsTestId($page, 'thread-reply-0'); + waitForTestId($page, 'thread-reply-0'); $page->fill('@thread-reply-0', "\u{200B}\u{00A0}"); - waitForChannelSettingsCondition($page, "document.querySelector('[data-testid=\"composer-submit\"]')?.getAttribute('aria-disabled') === 'true'"); + waitForTestIdState($page, 'composer-submit', "element.getAttribute('aria-disabled') === 'true'"); expect($page->script('document.querySelector(\'[data-testid="composer-submit"]\').getAttribute(\'aria-disabled\')'))->toBe('true'); $page->assertNoJavaScriptErrors(); @@ -1006,13 +988,13 @@ public function displayText(string $content, Platform $platform): string $post = seedChannelSettingsPost(Platform::Bluesky, ContentType::BlueskyPost); $page = visit(route('app.posts.edit', $post))->resize(1280, 900); - waitForChannelSettingsTestId($page, 'thread-start'); + waitForTestId($page, 'thread-start'); $page->click('@thread-start'); - waitForChannelSettingsTestId($page, 'thread-reply-0'); + waitForTestId($page, 'thread-reply-0'); $page->fill('@thread-reply-0', 'Fine')->click('@thread-add-reply'); - waitForChannelSettingsTestId($page, 'thread-reply-1'); + waitForTestId($page, 'thread-reply-1'); $page->fill('@thread-reply-1', 'Too long for the server')->click('@composer-submit'); - waitForChannelSettingsTestId($page, 'thread-reply-error-1'); + waitForTestId($page, 'thread-reply-error-1'); $page->assertSeeIn('@thread-reply-error-1', __('posts.form.thread.reply_too_long', ['limit' => 300, 'over' => 100])) ->assertMissing('@thread-reply-error-0') @@ -1027,7 +1009,7 @@ public function displayText(string $content, Platform $platform): string $post->update(['meta' => ['thread_replies' => [['text' => 'With a photo', 'media' => [MediaItem::fromMedia($image)->toArray()]], 'Plain reply']]]); $page = visit(route('app.posts.edit', $post))->resize(1280, 900); - waitForChannelSettingsTestId($page, "composer-{$id}-reply-0-media-item-0"); + waitForTestId($page, "composer-{$id}-reply-0-media-item-0"); $page->assertMissing("@composer-{$id}-media-item-0") ->assertMissing("@composer-{$id}-reply-0-dropzone") @@ -1036,7 +1018,7 @@ public function displayText(string $content, Platform $platform): string ->assertPresent("@composer-{$id}-media-source-menu") ->assertNotPresent("@composer-{$id}-add-media") ->click('@thread-reply-collapsed-0'); - waitForChannelSettingsTestId($page, 'thread-reply-0'); + waitForTestId($page, 'thread-reply-0'); $page->assertPresent("@composer-{$id}-reply-0-media-item-0") ->assertPresent("@composer-{$id}-reply-0-dropzone") ->assertMissing("@composer-{$id}-dropzone"); @@ -1063,7 +1045,7 @@ public function displayText(string $content, Platform $platform): string $post->update(['meta' => ['spoiler_text' => 'Spoilers', 'thread_replies' => ['Hidden reply']]]); $page = visit(route('app.posts.edit', $post))->resize(1280, 900); - waitForChannelSettingsTestId($page, 'mastodon-preview-content-warning'); + waitForTestId($page, 'mastodon-preview-content-warning'); expect($page->script("[...document.querySelectorAll('[data-testid=\"preview-thread\"] [data-testid=\"mastodon-preview-content-warning\"]')].map((warning) => warning.innerText.replace(/\\s+/g, ' ').trim())")) ->toBe(array_fill(0, 2, 'Spoilers '.__('posts.composer.preview.tap_to_reveal'))); @@ -1080,13 +1062,13 @@ public function displayText(string $content, Platform $platform): string $id = $post->social_account_id; $page = visit(route('app.posts.edit', $post))->resize(1280, 900); - waitForChannelSettingsTestId($page, 'thread-start'); + waitForTestId($page, 'thread-start'); $page->click('@thread-start'); - waitForChannelSettingsTestId($page, 'thread-reply-0'); + waitForTestId($page, 'thread-reply-0'); $page->fill('@thread-reply-0', 'Second tweet') ->assertSeeIn("@composer-char-count-{$id}", (string) ($limit - mb_strlen('Second tweet'))); - waitForChannelSettingsTestId($page, 'preview-thread-reply-0'); + waitForTestId($page, 'preview-thread-reply-0'); $page->assertSeeIn('@preview-thread-reply-0', 'Second tweet') ->assertNoJavaScriptErrors(); @@ -1103,7 +1085,7 @@ public function displayText(string $content, Platform $platform): string $post = seedChannelSettingsPost(Platform::X, ContentType::XPost); $page = visit(route('app.posts.edit', $post))->resize(1280, 900); - waitForChannelSettingsTestId($page, 'x-ai-generated'); + waitForTestId($page, 'x-ai-generated'); $page->assertVisible('@x-ai-generated-info') ->click('@x-ai-generated') @@ -1120,25 +1102,25 @@ public function displayText(string $content, Platform $platform): string $id = $post->social_account_id; $page = visit(route('app.posts.edit', $post))->resize(1280, 900); - waitForChannelSettingsTestId($page, "composer-caption-{$id}"); + waitForTestId($page, "composer-caption-{$id}"); $page->keys("@composer-caption-{$id}", 'Shift+Enter'); - waitForChannelSettingsTestId($page, 'thread-reply-0'); + waitForTestId($page, 'thread-reply-0'); $page->fill('@thread-reply-0', 'Second tweet') ->keys('@thread-reply-0', 'Shift+Enter'); - waitForChannelSettingsTestId($page, 'thread-reply-1'); + waitForTestId($page, 'thread-reply-1'); expect($page->script('document.activeElement?.dataset.testid'))->toBe('thread-reply-1'); $page->keys('@thread-reply-1', 'Backspace'); - waitForChannelSettingsTestId($page, 'thread-reply-0'); + waitForTestId($page, 'thread-reply-0'); expect($page->script("document.querySelectorAll('[data-testid^=\"thread-reply-collapsed-\"], [data-testid^=\"thread-reply-\"]:not([data-testid*=\"remove\"]):not([data-testid*=\"error\"]):not([data-testid*=\"collapsed\"])').length"))->toBe(1) ->and($page->script('document.activeElement?.dataset.testid'))->toBe('thread-reply-0'); $page->fill('@thread-reply-0', '') ->keys('@thread-reply-0', 'Backspace'); - waitForChannelSettingsTestId($page, "composer-caption-{$id}"); + waitForTestId($page, "composer-caption-{$id}"); expect($page->script('document.activeElement?.dataset.testid'))->toBe("composer-caption-{$id}"); $page->assertMissing('@thread-reply-0') @@ -1154,28 +1136,28 @@ public function displayText(string $content, Platform $platform): string $id = $post->social_account_id; $page = visit(route('app.posts.edit', $post))->resize(1440, 1000); - waitForChannelSettingsTestId($page, "composer-caption-{$id}"); + waitForTestId($page, "composer-caption-{$id}"); $page->script("(() => { const c = document.querySelector('[data-testid=\"composer-caption-{$id}\"]'); c.focus(); c.setSelectionRange(c.value.length, c.value.length); })()"); $page->keys("@composer-caption-{$id}", 'ArrowDown'); - waitForChannelSettingsTestId($page, 'thread-reply-0'); + waitForTestId($page, 'thread-reply-0'); expect($page->script('[document.activeElement?.dataset.testid, document.activeElement?.selectionStart]'))->toBe(['thread-reply-0', 0]); $page->script("(() => { const r = document.querySelector('[data-testid=\"thread-reply-0\"]'); r.setSelectionRange(r.value.length, r.value.length); })()"); $page->keys('@thread-reply-0', 'ArrowDown'); - waitForChannelSettingsTestId($page, 'thread-reply-1'); + waitForTestId($page, 'thread-reply-1'); expect($page->script('document.activeElement?.dataset.testid'))->toBe('thread-reply-1'); $page->keys('@thread-reply-1', 'ArrowUp'); - waitForChannelSettingsTestId($page, 'thread-reply-0'); + waitForTestId($page, 'thread-reply-0'); expect($page->script('[document.activeElement?.dataset.testid, document.activeElement?.selectionStart === document.activeElement?.value.length]'))->toBe(['thread-reply-0', true]); $page->script("document.querySelector('[data-testid=\"thread-reply-0\"]').setSelectionRange(0, 0)"); $page->keys('@thread-reply-0', 'ArrowUp'); - waitForChannelSettingsTestId($page, "composer-caption-{$id}"); + waitForTestId($page, "composer-caption-{$id}"); expect($page->script('document.activeElement?.dataset.testid'))->toBe("composer-caption-{$id}"); $page->assertNoJavaScriptErrors(); @@ -1189,10 +1171,10 @@ public function displayText(string $content, Platform $platform): string $id = $post->social_account_id; $page = visit(route('app.posts.edit', $post))->resize(1280, 700); - waitForChannelSettingsTestId($page, 'thread-reply-collapsed-2'); + waitForTestId($page, 'thread-reply-collapsed-2'); $page->click('@thread-reply-collapsed-2'); - waitForChannelSettingsCondition($page, <<<'JS' + waitForCondition($page, <<<'JS' (() => { const reply = document.querySelector('[data-testid="preview-thread-reply-2"]').getBoundingClientRect(); return reply.top >= 0 && reply.bottom <= window.innerHeight; @@ -1208,11 +1190,11 @@ public function displayText(string $content, Platform $platform): string $post->update(['content' => 'Scheduled post', 'status' => Status::Scheduled, 'scheduled_at' => now()->addDays(2)]); $page = visit(route('app.posts.edit', $post))->resize(1440, 1000); - waitForChannelSettingsTestId($page, 'composer-save-draft'); + waitForTestId($page, 'composer-save-draft'); $page->assertSeeIn('@composer-save-draft', __('posts.publish.actions.move_to_drafts')) ->click('@composer-save-draft'); - waitForChannelSettingsCondition($page, "!document.querySelector('[data-testid=\"composer-save-draft\"]')"); + waitForTestIdGone($page, 'composer-save-draft'); expect($post->fresh()->status)->toBe(Status::Draft); $page->assertNoJavaScriptErrors(); @@ -1223,7 +1205,7 @@ public function displayText(string $content, Platform $platform): string $post = seedChannelSettingsPost(Platform::GoogleBusiness, ContentType::GoogleBusinessPost); $page = visit(route('app.posts.edit', $post))->resize(1440, 1000); - waitForChannelSettingsTestId($page, 'google-business-preview'); + waitForTestId($page, 'google-business-preview'); expect($page->script("document.querySelector('[data-testid=\"google-business-preview\"] .text-info.rounded-full, [data-testid=\"google-business-preview\"] [data-testid=\"preview-verified\"]') === null"))->toBeTrue(); $page->assertNoJavaScriptErrors(); @@ -1237,11 +1219,11 @@ public function displayText(string $content, Platform $platform): string $id = $post->social_account_id; $page = visit(route('app.posts.edit', $post))->resize(1440, 1000); - waitForChannelSettingsTestId($page, "composer-meta-warning-{$id}"); + waitForTestId($page, "composer-meta-warning-{$id}"); $page->assertSeeIn("@composer-meta-warning-{$id}", __('posts.form.youtube.title_required')) ->fill('@youtube-title', 'My short'); - waitForChannelSettingsCondition($page, "!document.querySelector('[data-testid=\"composer-meta-warning-{$id}\"]')"); + waitForTestIdGone($page, "composer-meta-warning-{$id}"); $page->assertMissing("@composer-meta-warning-{$id}") ->assertNoJavaScriptErrors(); @@ -1252,7 +1234,7 @@ public function displayText(string $content, Platform $platform): string $post = seedChannelSettingsPost(Platform::TikTok, ContentType::TikTokVideo); $page = visit(route('app.posts.edit', $post))->resize(1280, 900); - waitForChannelSettingsTestId($page, 'tiktok-privacy-level'); + waitForTestId($page, 'tiktok-privacy-level'); $page->assertMissing('@tiktok-posting-to') ->assertVisible('@tiktok-disclose') @@ -1279,7 +1261,7 @@ public function displayText(string $content, Platform $platform): string $id = $post->social_account_id; $page = visit(route('app.posts.edit', $post))->resize(1440, 1000); - waitForChannelSettingsTestId($page, 'tiktok-privacy-level'); + waitForTestId($page, 'tiktok-privacy-level'); $page->assertMissing("@composer-type-{$id}-tiktok_video") ->assertMissing("@composer-type-{$id}-tiktok_photo") @@ -1288,9 +1270,9 @@ public function displayText(string $content, Platform $platform): string $page->assertVisible('@tiktok-auto-add-music') ->assertAttribute('@tiktok-auto-add-music', 'data-state', 'unchecked') ->click('@tiktok-auto-add-music'); - waitForChannelSettingsCondition($page, "document.querySelector('[data-testid=\"tiktok-auto-add-music\"]')?.dataset.state === 'checked'"); + waitForTestIdState($page, 'tiktok-auto-add-music', "element.dataset.state === 'checked'"); $page->click('@composer-save-draft'); - waitForChannelSettingsCondition($page, "!document.querySelector('[data-testid=\"post-composer-dialog\"]')"); + waitForTestIdGone($page, 'post-composer-dialog'); expect($post->fresh()->meta['auto_add_music'] ?? null)->toBeTrue(); $page->assertNoJavaScriptErrors(); @@ -1305,7 +1287,7 @@ public function displayText(string $content, Platform $platform): string $id = $post->social_account_id; $page = visit(route('app.posts.edit', $post))->resize(1440, 1000); - waitForChannelSettingsTestId($page, "composer-media-warning-{$id}"); + waitForTestId($page, "composer-media-warning-{$id}"); $page->assertSeeIn("@composer-media-warning-{$id}", __("posts.form.warnings.{$warning}", $params)) ->assertNoJavaScriptErrors(); @@ -1321,7 +1303,7 @@ public function displayText(string $content, Platform $platform): string $id = $post->social_account_id; $page = visit(route('app.posts.edit', $post))->resize(1440, 1000); - waitForChannelSettingsTestId($page, "composer-{$id}-media-item-1"); + waitForTestId($page, "composer-{$id}-media-item-1"); foreach ([ "composer-{$id}-drag-handle-0" => 'reorder', @@ -1331,9 +1313,9 @@ public function displayText(string $content, Platform $platform): string "composer-{$id}-edit-0" => 'edit', ] as $testId => $key) { $page->hover('@composer-save-draft'); - waitForChannelSettingsCondition($page, "!document.querySelector('[data-slot=\"tooltip-content\"]')"); + waitForCondition($page, "!document.querySelector('[data-slot=\"tooltip-content\"]')"); $page->hover("@composer-{$id}-media-item-0")->hover("@{$testId}"); - waitForChannelSettingsCondition($page, "[...document.querySelectorAll('[data-slot=\"tooltip-content\"]')].some((tip) => tip.textContent.includes(".json_encode(__("posts.composer.media_actions.{$key}")).'))'); + waitForCondition($page, "[...document.querySelectorAll('[data-slot=\"tooltip-content\"]')].some((tip) => tip.textContent.includes(".json_encode(__("posts.composer.media_actions.{$key}")).'))'); expect($page->script("[...document.querySelectorAll('[data-slot=\"tooltip-content\"]')].map((tip) => tip.textContent.trim()).join('|')")) ->toContain(__("posts.composer.media_actions.{$key}")); @@ -1348,7 +1330,7 @@ public function displayText(string $content, Platform $platform): string $post = seedChannelSettingsPost(Platform::TikTok, ContentType::TikTokPhoto, $photo); $page = visit(route('app.posts.edit', $post))->resize(1440, 1000); - waitForChannelSettingsTestId($page, 'tiktok-preview'); + waitForTestId($page, 'tiktok-preview'); expect($page->script("document.querySelectorAll('[data-testid=\"tiktok-preview\"] img[aria-hidden=\"true\"]').length"))->toBe(0) ->and($page->script("document.querySelectorAll('[data-testid=\"tiktok-preview\"] img').length"))->toBeGreaterThan(0); @@ -1362,12 +1344,12 @@ public function displayText(string $content, Platform $platform): string $post->update(['meta' => ['topic_type' => 'EVENT', 'event' => ['title' => 'Launch night', 'start_date' => channelSettingsEventDay(0), 'end_date' => channelSettingsEventDay(2), 'start_time' => '09:00']]]); $page = visit(route('app.posts.edit', $post))->resize(1280, 900); - waitForChannelSettingsTestId($page, 'google-business-start-time'); + waitForTestId($page, 'google-business-start-time'); expect($page->script("document.querySelector('[data-testid=\"google-business-start-time\"]').type"))->toBe('text'); $page->click('@google-business-start-time'); - waitForChannelSettingsTestId($page, 'time-field-option-0900'); + waitForTestId($page, 'time-field-option-0900'); expect($page->script("document.querySelector('[data-testid=\"time-field-option-1830\"]').textContent.trim()"))->toBe($shown); $page->assertAttribute('@time-field-option-0900', 'aria-selected', 'true') ->click('@time-field-option-1830'); @@ -1396,7 +1378,7 @@ public function displayText(string $content, Platform $platform): string $post->update(['media' => $images->map(fn (Media $media): array => MediaItem::fromMedia($media)->toArray())->all()]); $page = visit(route('app.posts.edit', $post))->resize(1440, 1000); - waitForChannelSettingsTestId($page, 'preview-media-collage'); + waitForTestId($page, 'preview-media-collage'); $layout = $page->script(<<<'JS' (() => { @@ -1422,7 +1404,7 @@ public function displayText(string $content, Platform $platform): string $testId = str_replace('{id}', $post->social_account_id, $counter); $page = visit(route('app.posts.edit', $post))->resize(1440, 1000); - waitForChannelSettingsTestId($page, $testId); + waitForTestId($page, $testId); $look = $page->script(<< { @@ -1465,14 +1447,14 @@ public function displayText(string $content, Platform $platform): string $post = seedChannelSettingsPost(Platform::InstagramFacebook, ContentType::InstagramReel); $page = visit(route('app.posts.edit', $post))->resize(1280, 900); - waitForChannelSettingsTestId($page, 'instagram-music-add'); + waitForTestId($page, 'instagram-music-add'); $page->click('@instagram-music-add'); - waitForChannelSettingsTestId($page, 'instagram-music-failed'); + waitForTestId($page, 'instagram-music-failed'); $page->assertSeeIn('@instagram-music-failed', __('posts.form.instagram.music.search_failed')) ->keys('@instagram-music-search', 'Escape'); - waitForChannelSettingsCondition($page, "!document.querySelector('[data-testid=\"instagram-music-picker\"]')"); + waitForTestIdGone($page, 'instagram-music-picker'); $page->assertNoJavaScriptErrors(); }); @@ -1486,12 +1468,12 @@ public function displayText(string $content, Platform $platform): string $post = seedChannelSettingsPost(Platform::InstagramFacebook, ContentType::InstagramReel); $page = visit(route('app.posts.edit', $post))->resize(1280, 900); - waitForChannelSettingsTestId($page, 'instagram-music-add'); + waitForTestId($page, 'instagram-music-add'); $page->click('@instagram-music-add'); - waitForChannelSettingsTestId($page, 'instagram-music-track-audio-trending'); + waitForTestId($page, 'instagram-music-track-audio-trending'); $page->type('@instagram-music-search', 'grudge'); - waitForChannelSettingsTestId($page, 'instagram-music-track-audio-grudge'); + waitForTestId($page, 'instagram-music-track-audio-grudge'); $page->assertMissing('@instagram-music-track-audio-trending')->assertNoJavaScriptErrors(); $musicSearches = collect(Http::recorded()) @@ -1513,22 +1495,22 @@ public function displayText(string $content, Platform $platform): string ]]]); $page = visit(route('app.posts.edit', $post))->resize(1280, 900); - waitForChannelSettingsTestId($page, 'instagram-music-selected-preview'); + waitForTestId($page, 'instagram-music-selected-preview'); $page->script('HTMLMediaElement.prototype.play = function () { return Promise.resolve(); }; HTMLMediaElement.prototype.pause = function () {};'); $page->click('@instagram-music-selected-preview'); - waitForChannelSettingsCondition($page, "document.querySelector('[data-testid=\"instagram-music-selected-preview\"]')?.getAttribute('aria-label') === '".__('posts.form.instagram.music.pause')."'"); + waitForTestIdState($page, 'instagram-music-selected-preview', "element.getAttribute('aria-label') === '".__('posts.form.instagram.music.pause')."'"); $page->assertAttribute('@instagram-music-selected-preview', 'aria-label', __('posts.form.instagram.music.pause')); $page->click('@instagram-music-selected-preview'); - waitForChannelSettingsCondition($page, "document.querySelector('[data-testid=\"instagram-music-selected-preview\"]')?.getAttribute('aria-label') === '".__('posts.form.instagram.music.play')."'"); + waitForTestIdState($page, 'instagram-music-selected-preview', "element.getAttribute('aria-label') === '".__('posts.form.instagram.music.play')."'"); $page->assertAttribute('@instagram-music-selected-preview', 'aria-label', __('posts.form.instagram.music.play')); $page->click('@instagram-music-remove'); - waitForChannelSettingsTestId($page, 'instagram-music-add'); + waitForTestId($page, 'instagram-music-add'); $page->assertMissing('@instagram-music-selected') ->click('@composer-save-draft'); - waitForChannelSettingsCondition($page, "!document.querySelector('[data-testid=\"post-composer-dialog\"]')"); + waitForTestIdGone($page, 'post-composer-dialog'); $page->assertNoJavaScriptErrors(); expect(data_get($post->fresh()->meta, 'audio'))->toBeNull(); @@ -1540,7 +1522,7 @@ public function displayText(string $content, Platform $platform): string $post->update(['meta' => ['trial_graduation' => 'MANUAL']]); $page = visit(route('app.posts.edit', $post))->resize(1280, 900); - waitForChannelSettingsTestId($page, 'instagram-trial-reel-hint'); + waitForTestId($page, 'instagram-trial-reel-hint'); $page->assertSeeIn('@instagram-trial-reel-hint', __('posts.form.instagram.trial_reel.manual_hint')) ->assertSeeIn('@instagram-preview-trial', __('posts.form.instagram.trial_reel.badge')) @@ -1552,17 +1534,17 @@ public function displayText(string $content, Platform $platform): string $post = seedChannelSettingsPost(Platform::Instagram, ContentType::InstagramReel); $page = visit(route('app.posts.edit', $post))->resize(1280, 900); - waitForChannelSettingsTestId($page, 'instagram-collaborator-input'); + waitForTestId($page, 'instagram-collaborator-input'); $page->type('@instagram-collaborator-input', '@alice') ->keys('@instagram-collaborator-input', 'Enter'); - waitForChannelSettingsTestId($page, 'instagram-collaborators-notice'); + waitForTestId($page, 'instagram-collaborators-notice'); $page->click('@instagram-collaborators-notice-dismiss') ->type('@instagram-collaborator-input', 'bob') ->click('@instagram-collaborator-add'); - waitForChannelSettingsTestId($page, 'instagram-collaborator-bob'); + waitForTestId($page, 'instagram-collaborator-bob'); $page->click('@composer-save-draft'); - waitForChannelSettingsCondition($page, "!document.querySelector('[data-testid=\"post-composer-dialog\"]')"); + waitForTestIdGone($page, 'post-composer-dialog'); $page->assertNoJavaScriptErrors(); expect(data_get($post->fresh()->meta, 'collaborators'))->toBe(['alice', 'bob']); @@ -1573,10 +1555,10 @@ public function displayText(string $content, Platform $platform): string $post = seedChannelSettingsPost(Platform::Instagram, ContentType::InstagramReel); $page = visit(route('app.posts.edit', $post))->resize(1280, 900); - waitForChannelSettingsTestId($page, 'instagram-collaborator-input'); + waitForTestId($page, 'instagram-collaborator-input'); $page->type('@instagram-collaborator-input', '@alice') ->click('@composer-save-draft'); - waitForChannelSettingsCondition($page, "!document.querySelector('[data-testid=\"post-composer-dialog\"]')"); + waitForTestIdGone($page, 'post-composer-dialog'); $page->assertNoJavaScriptErrors(); expect(data_get($post->fresh()->meta, 'collaborators'))->toBe(['alice']); @@ -1596,8 +1578,8 @@ public function displayText(string $content, Platform $platform): string ]]]); $page = visit(route('app.posts.edit', $post))->resize(1280, 900); - waitForChannelSettingsTestId($page, 'instagram-music-selected-preview'); - waitForChannelSettingsCondition($page, "document.querySelector('[data-testid=\"instagram-reel-preview\"] video')"); + waitForTestId($page, 'instagram-music-selected-preview'); + waitForTestIdState($page, 'instagram-reel-preview', "element.querySelector('video')"); $page->script(<<<'JS' (() => { const where = (media, suffix = '') => `${media.closest?.('[data-testid="instagram-reel-preview"]') ? 'PREVIEW' : media.tagName}${suffix}`; @@ -1620,14 +1602,14 @@ public function displayText(string $content, Platform $platform): string JS); $page->script("document.querySelector('[data-testid=\"instagram-reel-preview\"] video').parentElement.click()"); - waitForChannelSettingsCondition($page, "window.__played.includes('AUDIO')"); + waitForCondition($page, "window.__played.includes('AUDIO')"); expect($page->script('window.__played'))->toBe(['PREVIEW', 'AUDIO']) ->and($page->script('window.__audioVolume'))->toBe(40) ->and($page->script("Math.round(document.querySelector('[data-testid=\"instagram-reel-preview\"] video').volume * 100)"))->toBe(30); $page->script("document.querySelector('[data-testid=\"instagram-reel-preview\"] video').parentElement.click()"); - waitForChannelSettingsCondition($page, "window.__played.includes('AUDIO:paused')"); + waitForCondition($page, "window.__played.includes('AUDIO:paused')"); expect($page->script("window.__played.includes('PREVIEW:paused') && window.__played.includes('AUDIO:paused')"))->toBeTrue(); $page->assertNoJavaScriptErrors(); @@ -1646,8 +1628,8 @@ public function displayText(string $content, Platform $platform): string ]]]); $page = visit(route('app.posts.edit', $post))->resize(1280, 900); - waitForChannelSettingsTestId($page, 'instagram-music-selected-preview'); - waitForChannelSettingsCondition($page, "document.querySelector('[data-testid=\"instagram-reel-preview\"] video')"); + waitForTestId($page, 'instagram-music-selected-preview'); + waitForTestIdState($page, 'instagram-reel-preview', "element.querySelector('video')"); $page->script(<<<'JS' (() => { const where = (media, suffix = '') => `${media.closest?.('[data-testid="instagram-reel-preview"]') ? 'PREVIEW' : media.tagName}${suffix}`; @@ -1660,7 +1642,7 @@ public function displayText(string $content, Platform $platform): string $page->assertSeeIn('@instagram-preview-audio', 'Summer Song · The Band') ->click('@instagram-music-selected-preview'); - waitForChannelSettingsCondition($page, "window.__played.includes('AUDIO')"); + waitForCondition($page, "window.__played.includes('AUDIO')"); $page->script('new Promise((resolve) => setTimeout(resolve, 300))'); expect($page->script('window.__played'))->not->toContain('PREVIEW'); @@ -1673,7 +1655,7 @@ public function displayText(string $content, Platform $platform): string $post->update(['meta' => ['collaborators' => ['alice']]]); $page = visit(route('app.posts.edit', $post))->resize(1280, 900); - waitForChannelSettingsTestId($page, 'instagram-collaborator-alice'); + waitForTestId($page, 'instagram-collaborator-alice'); expect($page->script("getComputedStyle(document.querySelector('[data-testid=\"instagram-collaborator-alice\"]')).cursor"))->toBe('alias') ->and($page->script("document.querySelector('[data-testid=\"instagram-collaborator-alice\"]').target"))->toBe('_blank'); diff --git a/tests/Browser/ComposerUnsavedChangesTest.php b/tests/Browser/ComposerUnsavedChangesTest.php index a14995f6e2..a7446cef21 100644 --- a/tests/Browser/ComposerUnsavedChangesTest.php +++ b/tests/Browser/ComposerUnsavedChangesTest.php @@ -31,30 +31,13 @@ function seedUnsavedChangesReel(): Post return $post; } -function waitForUnsavedChangesCondition(mixed $page, string $condition): void -{ - $page->script(<< { - for (let i = 0; i < 300; i++) { - if ({$condition}) return; - await new Promise((resolve) => setTimeout(resolve, 50)); - } - })(); - JS); -} - -function waitForUnsavedChangesTestId(mixed $page, string $testId): void -{ - waitForUnsavedChangesCondition($page, "document.querySelector('[data-testid=\"{$testId}\"]')?.getBoundingClientRect().height > 0"); -} - test('closing an edited post without changes asks nothing', function () { $post = seedUnsavedChangesReel(); $page = visit(route('app.posts.edit', $post))->resize(1280, 900); - waitForUnsavedChangesTestId($page, 'instagram-share-to-feed'); + waitForTestId($page, 'instagram-share-to-feed'); $page->click('@composer-close'); - waitForUnsavedChangesCondition($page, "!document.querySelector('[data-testid=\"post-composer-dialog\"]')"); + waitForTestIdGone($page, 'post-composer-dialog'); $page->assertMissing('@post-composer-dialog') ->assertMissing('@unsaved-changes-dialog') @@ -65,18 +48,18 @@ function waitForUnsavedChangesTestId(mixed $page, string $testId): void $post = seedUnsavedChangesReel(); $page = visit(route('app.posts.edit', $post))->resize(1280, 900); - waitForUnsavedChangesTestId($page, 'instagram-share-to-feed'); + waitForTestId($page, 'instagram-share-to-feed'); $page->click('@instagram-share-to-feed') ->click('@composer-close'); - waitForUnsavedChangesTestId($page, 'unsaved-changes-dialog'); + waitForTestId($page, 'unsaved-changes-dialog'); $page->click('@unsaved-changes-keep'); - waitForUnsavedChangesCondition($page, "!document.querySelector('[data-testid=\"unsaved-changes-dialog\"]')"); + waitForTestIdGone($page, 'unsaved-changes-dialog'); $page->assertVisible('@post-composer-dialog') ->click('@composer-close'); - waitForUnsavedChangesTestId($page, 'unsaved-changes-discard'); + waitForTestId($page, 'unsaved-changes-discard'); $page->click('@unsaved-changes-discard'); - waitForUnsavedChangesCondition($page, "!document.querySelector('[data-testid=\"post-composer-dialog\"]')"); + waitForTestIdGone($page, 'post-composer-dialog'); $page->assertMissing('@post-composer-dialog')->assertNoJavaScriptErrors(); expect($post->fresh()->meta)->toBe([]); @@ -86,10 +69,10 @@ function waitForUnsavedChangesTestId(mixed $page, string $testId): void $post = seedUnsavedChangesReel(); $page = visit(route('app.posts.edit', $post))->resize(1280, 900); - waitForUnsavedChangesTestId($page, 'instagram-share-to-feed'); + waitForTestId($page, 'instagram-share-to-feed'); $page->click('@instagram-share-to-feed') ->click('@composer-save-draft'); - waitForUnsavedChangesCondition($page, "!document.querySelector('[data-testid=\"post-composer-dialog\"]')"); + waitForTestIdGone($page, 'post-composer-dialog'); $page->assertMissing('@unsaved-changes-dialog')->assertNoJavaScriptErrors(); expect(data_get($post->fresh()->meta, 'share_to_feed'))->toBeFalse(); @@ -101,12 +84,14 @@ function waitForUnsavedChangesTestId(mixed $page, string $testId): void $page->fill("@composer-caption-{$post->social_account_id}", 'Saved from the composer') ->click('@composer-save-draft'); - waitForUnsavedChangesCondition($page, "!document.querySelector('[data-testid=\"post-composer-dialog\"]') && !location.search.includes('edit=') && document.querySelector('[data-testid=\"post-card-{$post->id}\"]')?.textContent.includes('Saved from the composer')"); + waitForTestIdGone($page, 'post-composer-dialog'); + waitForCondition($page, "!location.search.includes('edit=')"); + waitForTestIdState($page, "post-card-{$post->id}", "element.textContent.includes('Saved from the composer')"); $page->assertPathIs(unsavedChangesListPath()) ->assertSeeIn("@post-card-{$post->id}", 'Saved from the composer'); $page->back(); - waitForUnsavedChangesCondition($page, "location.pathname === '".parse_url(route('app.settings.preferences'), PHP_URL_PATH)."'"); + waitForPath($page, parse_url(route('app.settings.preferences'), PHP_URL_PATH)); $page->assertPathIs((string) parse_url(route('app.settings.preferences'), PHP_URL_PATH)) ->assertNoJavaScriptErrors(); @@ -120,9 +105,11 @@ function waitForUnsavedChangesTestId(mixed $page, string $testId): void $page->fill("@composer-caption-{$post->social_account_id}", 'Saved after a partial reload') ->click('@composer-save-draft'); - waitForUnsavedChangesCondition($page, "!document.querySelector('[data-testid=\"post-composer-dialog\"]') && !location.search.includes('edit=') && document.querySelector('[data-testid=\"post-card-{$post->id}\"]')?.textContent.includes('Saved after a partial reload')"); + waitForTestIdGone($page, 'post-composer-dialog'); + waitForCondition($page, "!location.search.includes('edit=')"); + waitForTestIdState($page, "post-card-{$post->id}", "element.textContent.includes('Saved after a partial reload')"); $page->back(); - waitForUnsavedChangesCondition($page, "location.pathname === '".parse_url(route('app.settings.preferences'), PHP_URL_PATH)."'"); + waitForPath($page, parse_url(route('app.settings.preferences'), PHP_URL_PATH)); $page->assertPathIs((string) parse_url(route('app.settings.preferences'), PHP_URL_PATH)) ->assertNoJavaScriptErrors(); @@ -133,11 +120,12 @@ function waitForUnsavedChangesTestId(mixed $page, string $testId): void $page = openUnsavedChangesFromList($post, route('app.settings.preferences')); $page->click('@composer-close'); - waitForUnsavedChangesCondition($page, "!document.querySelector('[data-testid=\"post-composer-dialog\"]') && !location.search.includes('edit=')"); + waitForTestIdGone($page, 'post-composer-dialog'); + waitForCondition($page, "!location.search.includes('edit=')"); $page->assertPathIs(unsavedChangesListPath())->assertMissing('@unsaved-changes-dialog'); $page->back(); - waitForUnsavedChangesCondition($page, "location.pathname === '".parse_url(route('app.settings.preferences'), PHP_URL_PATH)."'"); + waitForPath($page, parse_url(route('app.settings.preferences'), PHP_URL_PATH)); $page->assertPathIs((string) parse_url(route('app.settings.preferences'), PHP_URL_PATH)) ->assertNoJavaScriptErrors(); @@ -147,7 +135,7 @@ function waitForUnsavedChangesTestId(mixed $page, string $testId): void $post = seedUnsavedChangesReel(); $page = visit(route('app.posts.edit', $post))->resize(1280, 900); - waitForUnsavedChangesTestId($page, 'instagram-share-to-feed'); + waitForTestId($page, 'instagram-share-to-feed'); $page->script(<<<'JS' (() => { window.__backs = 0; @@ -158,7 +146,8 @@ function waitForUnsavedChangesTestId(mixed $page, string $testId): void JS); $page->click($leave === 'save' ? '@composer-save-draft' : '@composer-close'); - waitForUnsavedChangesCondition($page, "!document.querySelector('[data-testid=\"post-composer-dialog\"]') && !location.search.includes('edit=')"); + waitForTestIdGone($page, 'post-composer-dialog'); + waitForCondition($page, "!location.search.includes('edit=')"); $page->script('new Promise((resolve) => setTimeout(resolve, 500))'); $page->assertPathIs(unsavedChangesListPath()) @@ -187,22 +176,23 @@ function waitForUnsavedChangesTestId(mixed $page, string $testId): void $page = visit(route('app.settings.preferences'))->resize(1280, 900)->navigate($list); if ($from === 'calendar') { - waitForUnsavedChangesTestId($page, "calendar-post-{$post->id}"); + waitForTestId($page, "calendar-post-{$post->id}"); $page->click("@calendar-post-{$post->id}"); } - waitForUnsavedChangesTestId($page, "post-edit-{$post->id}"); + waitForTestId($page, "post-edit-{$post->id}"); $page->click("@post-edit-{$post->id}"); - waitForUnsavedChangesTestId($page, 'instagram-share-to-feed'); + waitForTestId($page, 'instagram-share-to-feed'); $page->click('@instagram-share-to-feed') ->click('@composer-save-draft'); - waitForUnsavedChangesCondition($page, "!document.querySelector('[data-testid=\"post-composer-dialog\"]')"); + waitForTestIdGone($page, 'post-composer-dialog'); $page->script('new Promise((resolve) => setTimeout(resolve, 500))'); - waitForUnsavedChangesCondition($page, "location.pathname === '{$listPath}' && !location.search.includes('edit=')"); + waitForPath($page, $listPath); + waitForCondition($page, "!location.search.includes('edit=')"); $page->assertPathIs($listPath)->assertMissing('@unsaved-changes-dialog'); $page->back(); - waitForUnsavedChangesCondition($page, "location.pathname === '{$before}'"); + waitForPath($page, $before); $page->assertPathIs($before)->assertNoJavaScriptErrors(); expect(data_get($post->fresh()->meta, 'share_to_feed'))->toBeFalse(); @@ -232,7 +222,7 @@ function waitForUnsavedChangesTestId(mixed $page, string $testId): void $page->click('@instagram-share-to-feed') ->click('@composer-save-draft'); - waitForUnsavedChangesCondition($page, "!document.querySelector('[data-testid=\"post-composer-dialog\"]')"); + waitForTestIdGone($page, 'post-composer-dialog'); $page->script('new Promise((resolve) => setTimeout(resolve, 500))'); expect($page->script('window.__saved'))->toBeTrue() @@ -268,7 +258,7 @@ function waitForUnsavedChangesTestId(mixed $page, string $testId): void $page = visit(route('app.posts.edit', $post))->resize(1280, 900); if ($clickTextFirst) { - waitForUnsavedChangesTestId($page, 'composer-editor-column'); + waitForTestId($page, 'composer-editor-column'); $page->script(<<<'JS' (() => { const target = document.querySelector('[data-testid="composer-editor-column"] [contenteditable="true"]') ?? document.querySelector('[data-testid="composer-editor-column"]'); @@ -279,10 +269,10 @@ function waitForUnsavedChangesTestId(mixed $page, string $testId): void })() JS); } - waitForUnsavedChangesTestId($page, 'channel-settings-rows'); + waitForTestId($page, 'channel-settings-rows'); $page->script('new Promise((resolve) => setTimeout(resolve, 1000))'); $page->click('@composer-close'); - waitForUnsavedChangesCondition($page, "!document.querySelector('[data-testid=\"post-composer-dialog\"]') || document.querySelector('[data-testid=\"unsaved-changes-dialog\"]')"); + waitForTestIdGoneOrShown($page, gone: 'post-composer-dialog', shown: 'unsaved-changes-dialog'); $page->assertMissing('@unsaved-changes-dialog') ->assertMissing('@post-composer-dialog') @@ -303,10 +293,10 @@ function waitForUnsavedChangesTestId(mixed $page, string $testId): void $post->forceFill(['content_type' => $contentType, 'media' => $media, ...$schedule])->save(); $page = visit(route('app.posts.edit', $post))->resize(1280, 900); - waitForUnsavedChangesTestId($page, 'channel-settings-rows'); + waitForTestId($page, 'channel-settings-rows'); $page->script('new Promise((resolve) => setTimeout(resolve, 1000))'); $page->click('@composer-close'); - waitForUnsavedChangesCondition($page, "!document.querySelector('[data-testid=\"post-composer-dialog\"]')"); + waitForTestIdGone($page, 'post-composer-dialog'); $page->assertMissing('@unsaved-changes-dialog')->assertNoJavaScriptErrors(); })->with([ @@ -325,16 +315,16 @@ function waitForUnsavedChangesTestId(mixed $page, string $testId): void $post->forceFill(['status' => PostStatus::Scheduled, 'scheduled_at' => now()->addDay()])->save(); $page = visit(route('app.posts.edit', $post))->resize(1280, 900); - waitForUnsavedChangesTestId($page, 'composer-schedule-trigger'); + waitForTestId($page, 'composer-schedule-trigger'); $page->click('@composer-schedule-trigger'); - waitForUnsavedChangesTestId($page, 'composer-schedule-more-actions'); + waitForTestId($page, 'composer-schedule-more-actions'); $page->click('@composer-schedule-more-actions'); - waitForUnsavedChangesTestId($page, 'composer-schedule-now'); + waitForTestId($page, 'composer-schedule-now'); $page->click('@composer-schedule-now'); - waitForUnsavedChangesCondition($page, "document.querySelector('[data-testid=\"composer-submit\"]')?.dataset.scheduleMode === 'now'"); + waitForTestIdState($page, 'composer-submit', "element.dataset.scheduleMode === 'now'"); $page->click('@composer-close'); - waitForUnsavedChangesTestId($page, 'unsaved-changes-dialog'); + waitForTestId($page, 'unsaved-changes-dialog'); $page->assertVisible('@unsaved-changes-dialog')->assertNoJavaScriptErrors(); }); @@ -358,15 +348,15 @@ function waitForUnsavedChangesTestId(mixed $page, string $testId): void $this->actingAs($user); $page = visit(route('app.posts.edit', $post))->resize(1280, 900); - waitForUnsavedChangesCondition($page, "document.querySelector('[data-testid=\"channel-settings-rows\"] [role=\"combobox\"]:not([disabled])')"); + waitForCondition($page, "document.querySelector('[data-testid=\"channel-settings-rows\"] [role=\"combobox\"]:not([disabled])')"); $page->assertNoJavaScriptErrors() ->script("document.querySelector('[data-testid=\"channel-settings-rows\"] [role=\"combobox\"]').click()"); - waitForUnsavedChangesCondition($page, "[...document.querySelectorAll('[role=\"option\"]')].some((option) => option.textContent.includes('general'))"); + waitForCondition($page, "[...document.querySelectorAll('[role=\"option\"]')].some((option) => option.textContent.includes('general'))"); $page->script("[...document.querySelectorAll('[role=\"option\"]')].find((option) => option.textContent.includes('general')).click()"); - waitForUnsavedChangesCondition($page, "document.querySelector('[data-testid=\"channel-settings-rows\"] [role=\"combobox\"]')?.textContent.includes('general')"); + waitForCondition($page, "document.querySelector('[data-testid=\"channel-settings-rows\"] [role=\"combobox\"]')?.textContent.includes('general')"); $page->click('@composer-save-draft'); - waitForUnsavedChangesCondition($page, "!document.querySelector('[data-testid=\"post-composer-dialog\"]')"); + waitForTestIdGone($page, 'post-composer-dialog'); expect($post->fresh()->meta)->toMatchArray(['channel_id' => '111', 'channel_name' => 'general']); }); @@ -375,19 +365,19 @@ function waitForUnsavedChangesTestId(mixed $page, string $testId): void $post = seedUnsavedChangesReel(); $page = visit(route('app.posts.edit', $post))->resize(1280, 900); - waitForUnsavedChangesTestId($page, 'instagram-share-to-feed'); + waitForTestId($page, 'instagram-share-to-feed'); $page->click('@instagram-share-to-feed') ->script("document.querySelector('[data-testid=\"sidebar-logo\"]').click()"); - waitForUnsavedChangesTestId($page, 'unsaved-changes-dialog'); + waitForTestId($page, 'unsaved-changes-dialog'); $page->assertVisible('@post-composer-dialog') ->click('@unsaved-changes-keep'); - waitForUnsavedChangesCondition($page, "!document.querySelector('[data-testid=\"unsaved-changes-dialog\"]')"); + waitForTestIdGone($page, 'unsaved-changes-dialog'); $page->assertVisible('@post-composer-dialog') ->script("document.querySelector('[data-testid=\"sidebar-logo\"]').click()"); - waitForUnsavedChangesTestId($page, 'unsaved-changes-discard'); + waitForTestId($page, 'unsaved-changes-discard'); $page->click('@unsaved-changes-discard'); - waitForUnsavedChangesCondition($page, "!document.querySelector('[data-testid=\"post-composer-dialog\"]')"); + waitForTestIdGone($page, 'post-composer-dialog'); $page->assertMissing('@post-composer-dialog')->assertNoJavaScriptErrors(); expect($post->fresh()->meta)->toBe([]); @@ -404,9 +394,9 @@ function openUnsavedChangesFromList(Post $post, ?string $from = null, string $re $page = $from === null ? visit($list)->resize(1280, 900) : visit($from)->resize(1280, 900)->navigate($list); - waitForUnsavedChangesTestId($page, "post-edit-{$post->id}"); + waitForTestId($page, "post-edit-{$post->id}"); $page->click("@post-edit-{$post->id}"); - waitForUnsavedChangesTestId($page, $readyTestId); + waitForTestId($page, $readyTestId); return $page; } @@ -416,20 +406,20 @@ function openUnsavedChangesFromList(Post $post, ?string $from = null, string $re $page = openUnsavedChangesFromList($post); $page->click('@instagram-share-to-feed'); - waitForUnsavedChangesCondition($page, "document.querySelector('[data-testid=\"instagram-share-to-feed\"]')?.getAttribute('aria-checked') === 'false'"); + waitForTestIdState($page, 'instagram-share-to-feed', "element.getAttribute('aria-checked') === 'false'"); $page->back(); - waitForUnsavedChangesTestId($page, 'unsaved-changes-dialog'); + waitForTestId($page, 'unsaved-changes-dialog'); $page->assertVisible('@post-composer-dialog') ->click('@unsaved-changes-keep'); - waitForUnsavedChangesCondition($page, "!document.querySelector('[data-testid=\"unsaved-changes-dialog\"]')"); + waitForTestIdGone($page, 'unsaved-changes-dialog'); $page->assertVisible('@post-composer-dialog') ->assertAttribute('@instagram-share-to-feed', 'aria-checked', 'false'); $page->back(); - waitForUnsavedChangesTestId($page, 'unsaved-changes-discard'); + waitForTestId($page, 'unsaved-changes-discard'); $page->click('@unsaved-changes-discard'); - waitForUnsavedChangesCondition($page, "!document.querySelector('[data-testid=\"post-composer-dialog\"]')"); + waitForTestIdGone($page, 'post-composer-dialog'); $page->assertMissing('@post-composer-dialog') ->assertPathIs(unsavedChangesListPath()) @@ -442,7 +432,7 @@ function openUnsavedChangesFromList(Post $post, ?string $from = null, string $re $page = openUnsavedChangesFromList($post); $page->back(); - waitForUnsavedChangesCondition($page, "!document.querySelector('[data-testid=\"post-composer-dialog\"]')"); + waitForTestIdGone($page, 'post-composer-dialog'); $page->assertMissing('@post-composer-dialog') ->assertMissing('@unsaved-changes-dialog') @@ -457,7 +447,8 @@ function openUnsavedChangesFromList(Post $post, ?string $from = null, string $re $page->click('@instagram-share-to-feed') ->click('@composer-save-draft'); - waitForUnsavedChangesCondition($page, "!document.querySelector('[data-testid=\"post-composer-dialog\"]') && !location.search.includes('edit=')"); + waitForTestIdGone($page, 'post-composer-dialog'); + waitForCondition($page, "!location.search.includes('edit=')"); $page->back(); $page->script('new Promise((resolve) => setTimeout(resolve, 800))'); @@ -474,9 +465,9 @@ function openUnsavedChangesFromList(Post $post, ?string $from = null, string $re $page->click('@instagram-share-to-feed') ->click('@composer-close'); - waitForUnsavedChangesTestId($page, 'unsaved-changes-discard'); + waitForTestId($page, 'unsaved-changes-discard'); $page->click('@unsaved-changes-discard'); - waitForUnsavedChangesCondition($page, "!document.querySelector('[data-testid=\"post-composer-dialog\"]')"); + waitForTestIdGone($page, 'post-composer-dialog'); $page->back(); $page->script('new Promise((resolve) => setTimeout(resolve, 800))'); @@ -517,10 +508,10 @@ function openUnsavedChangesFromList(Post $post, ?string $from = null, string $re $this->actingAs($user); $page = visit(route('app.posts.edit', $post))->resize(1280, 900); - waitForUnsavedChangesTestId($page, 'channel-settings-rows'); - waitForUnsavedChangesCondition($page, $settledCondition); + waitForTestId($page, 'channel-settings-rows'); + waitForCondition($page, $settledCondition); $page->click('@composer-close'); - waitForUnsavedChangesCondition($page, "!document.querySelector('[data-testid=\"post-composer-dialog\"]')"); + waitForTestIdGone($page, 'post-composer-dialog'); $page->assertMissing('@unsaved-changes-dialog') ->assertMissing('@post-composer-dialog') @@ -550,13 +541,13 @@ function openUnsavedChangesFromList(Post $post, ?string $from = null, string $re $this->actingAs($user); $page = visit(route('app.posts.edit', $post))->resize(1280, 900); - waitForUnsavedChangesTestId($page, 'instagram-music-selected-cover'); + waitForTestId($page, 'instagram-music-selected-cover'); $page->script('new Promise((resolve) => setTimeout(resolve, 500))'); $page->script("document.querySelector('[data-testid=\"instagram-music-selected-cover\"]').dispatchEvent(new Event('error'))"); - waitForUnsavedChangesCondition($page, "document.querySelector('[data-testid=\"instagram-music-selected-cover\"]')?.getAttribute('src')?.endsWith('#fresh')"); + waitForTestIdState($page, 'instagram-music-selected-cover', "element.getAttribute('src')?.endsWith('#fresh')"); $page->assertAttributeContains('@instagram-music-selected-cover', 'src', '#fresh') ->click('@composer-close'); - waitForUnsavedChangesCondition($page, "!document.querySelector('[data-testid=\"post-composer-dialog\"]')"); + waitForTestIdGone($page, 'post-composer-dialog'); $page->assertMissing('@unsaved-changes-dialog') ->assertMissing('@post-composer-dialog') @@ -595,7 +586,7 @@ function seedUnsavedChangesReelWithBareTrack(): Post $post = seedUnsavedChangesReelWithBareTrack(); $page = visit(route('app.posts.edit', $post))->resize(1280, 900); - waitForUnsavedChangesCondition($page, "document.querySelector('[data-testid=\"instagram-music-selected-cover\"]')?.getAttribute('src')?.endsWith('#fresh')"); + waitForTestIdState($page, 'instagram-music-selected-cover', "element.getAttribute('src')?.endsWith('#fresh')"); $page->assertAttributeContains('@instagram-music-selected-cover', 'src', '#fresh') ->assertSeeIn('@instagram-music-selected', 'Summer Song') @@ -608,7 +599,7 @@ function seedUnsavedChangesReelWithBareTrack(): Post $post = seedUnsavedChangesReelWithBareTrack(); $page = visit(route('app.posts.index', ['tab' => 'drafts']))->resize(1280, 900); - waitForUnsavedChangesTestId($page, "post-edit-{$post->id}"); + waitForTestId($page, "post-edit-{$post->id}"); $page->script(<<<'JS' (() => { const open = XMLHttpRequest.prototype.open; @@ -627,7 +618,7 @@ function seedUnsavedChangesReelWithBareTrack(): Post })() JS); $page->click("@post-edit-{$post->id}"); - waitForUnsavedChangesTestId($page, 'composer-editor-column'); + waitForTestId($page, 'composer-editor-column'); $page->script(<<<'JS' (() => { const target = document.querySelector('[data-testid="composer-editor-column"] [contenteditable="true"]') ?? document.querySelector('[data-testid="composer-editor-column"]'); @@ -636,11 +627,11 @@ function seedUnsavedChangesReelWithBareTrack(): Post target.dispatchEvent(new PointerEvent('pointerup', options)); })() JS); - expect($page->script("Boolean(document.querySelector('[data-testid=\"instagram-music-selected-cover\"]'))"))->toBeFalse(); - waitForUnsavedChangesCondition($page, "document.querySelector('[data-testid=\"instagram-music-selected-cover\"]')?.getAttribute('src')?.endsWith('#fresh')"); + $page->assertMissing('@instagram-music-selected-cover'); + waitForTestIdState($page, 'instagram-music-selected-cover', "element.getAttribute('src')?.endsWith('#fresh')"); $page->assertAttributeContains('@instagram-music-selected-cover', 'src', '#fresh') ->click('@composer-close'); - waitForUnsavedChangesCondition($page, "!document.querySelector('[data-testid=\"post-composer-dialog\"]') || document.querySelector('[data-testid=\"unsaved-changes-dialog\"]')"); + waitForTestIdGoneOrShown($page, gone: 'post-composer-dialog', shown: 'unsaved-changes-dialog'); $page->assertMissing('@unsaved-changes-dialog') ->assertMissing('@post-composer-dialog') @@ -653,16 +644,16 @@ function seedUnsavedChangesReelWithBareTrack(): Post $page->click('@instagram-share-to-feed') ->script("document.querySelector('[data-testid=\"sidebar-logo\"]').click()"); - waitForUnsavedChangesTestId($page, 'unsaved-changes-discard'); + waitForTestId($page, 'unsaved-changes-discard'); $page->click('@unsaved-changes-discard'); - waitForUnsavedChangesCondition($page, "!document.querySelector('[data-testid=\"post-composer-dialog\"]')"); + waitForTestIdGone($page, 'post-composer-dialog'); $page->back(); - waitForUnsavedChangesTestId($page, 'instagram-share-to-feed'); + waitForTestId($page, 'instagram-share-to-feed'); $page->assertAttribute('@instagram-share-to-feed', 'aria-checked', 'true'); $page->back(); - waitForUnsavedChangesCondition($page, "!document.querySelector('[data-testid=\"post-composer-dialog\"]')"); + waitForTestIdGone($page, 'post-composer-dialog'); $page->assertMissing('@post-composer-dialog') ->assertMissing('@unsaved-changes-dialog') @@ -675,23 +666,23 @@ function seedUnsavedChangesReelWithBareTrack(): Post $page = openUnsavedChangesFromList($post); $page->click('@instagram-share-to-feed'); - waitForUnsavedChangesCondition($page, "document.querySelector('[data-testid=\"instagram-share-to-feed\"]')?.getAttribute('aria-checked') === 'false'"); + waitForTestIdState($page, 'instagram-share-to-feed', "element.getAttribute('aria-checked') === 'false'"); $page->back(); - waitForUnsavedChangesTestId($page, 'unsaved-changes-keep'); + waitForTestId($page, 'unsaved-changes-keep'); $page->click('@unsaved-changes-keep'); - waitForUnsavedChangesCondition($page, "!document.querySelector('[data-testid=\"unsaved-changes-dialog\"]')"); + waitForTestIdGone($page, 'unsaved-changes-dialog'); $page->script("document.querySelector('[data-testid=\"sidebar-logo\"]').click()"); - waitForUnsavedChangesTestId($page, 'unsaved-changes-discard'); + waitForTestId($page, 'unsaved-changes-discard'); $page->click('@unsaved-changes-discard'); - waitForUnsavedChangesCondition($page, "!document.querySelector('[data-testid=\"post-composer-dialog\"]')"); + waitForTestIdGone($page, 'post-composer-dialog'); $page->back(); - waitForUnsavedChangesTestId($page, 'instagram-share-to-feed'); + waitForTestId($page, 'instagram-share-to-feed'); $page->assertVisible('@post-composer-dialog') ->assertAttribute('@instagram-share-to-feed', 'aria-checked', 'true'); $page->back(); - waitForUnsavedChangesCondition($page, "!document.querySelector('[data-testid=\"post-composer-dialog\"]')"); + waitForTestIdGone($page, 'post-composer-dialog'); $page->assertMissing('@post-composer-dialog') ->assertMissing('@unsaved-changes-dialog') @@ -705,18 +696,18 @@ function seedUnsavedChangesReelWithBareTrack(): Post $listPath = unsavedChangesListPath(); $page->click('@instagram-share-to-feed'); - waitForUnsavedChangesCondition($page, "document.querySelector('[data-testid=\"instagram-share-to-feed\"]')?.getAttribute('aria-checked') === 'false'"); + waitForTestIdState($page, 'instagram-share-to-feed', "element.getAttribute('aria-checked') === 'false'"); $page->back(); - waitForUnsavedChangesTestId($page, 'unsaved-changes-keep'); + waitForTestId($page, 'unsaved-changes-keep'); $page->click('@unsaved-changes-keep'); - waitForUnsavedChangesCondition($page, "!document.querySelector('[data-testid=\"unsaved-changes-dialog\"]')"); + waitForTestIdGone($page, 'unsaved-changes-dialog'); $page->click('@composer-save-draft'); - waitForUnsavedChangesCondition($page, "!document.querySelector('[data-testid=\"post-composer-dialog\"]')"); + waitForTestIdGone($page, 'post-composer-dialog'); $page->script("document.querySelector('[data-testid=\"sidebar-logo\"]').click()"); - waitForUnsavedChangesCondition($page, "location.pathname !== '{$listPath}'"); + waitForCondition($page, "location.pathname !== '{$listPath}'"); $page->back(); - waitForUnsavedChangesTestId($page, "post-edit-{$post->id}"); + waitForTestId($page, "post-edit-{$post->id}"); $page->assertMissing('@unsaved-changes-dialog') ->assertPathIs($listPath); @@ -728,10 +719,10 @@ function seedUnsavedChangesReelWithBareTrack(): Post $post = seedUnsavedChangesReel(); $page = visit(route('app.posts.edit', $post))->resize(1280, 900); - waitForUnsavedChangesTestId($page, 'instagram-share-to-feed'); + waitForTestId($page, 'instagram-share-to-feed'); $page->click('@instagram-share-to-feed') ->keys('@instagram-share-to-feed', 'Escape'); - waitForUnsavedChangesTestId($page, 'unsaved-changes-dialog'); + waitForTestId($page, 'unsaved-changes-dialog'); $page->assertVisible('@unsaved-changes-dialog') ->assertVisible('@post-composer-dialog') @@ -743,14 +734,14 @@ function seedUnsavedChangesReelWithBareTrack(): Post $account = $post->socialAccount; $page = visit(route('app.posts.index'))->resize(1280, 900); - waitForUnsavedChangesTestId($page, 'posts-new-post'); + waitForTestId($page, 'posts-new-post'); $page->click('@posts-new-post'); - waitForUnsavedChangesTestId($page, 'composer-add-account'); + waitForTestId($page, 'composer-add-account'); $page->click('@composer-add-account') ->click("@composer-account-option-{$account->id}") ->fill("@composer-caption-{$account->id}", 'A new idea') ->click('@composer-close'); - waitForUnsavedChangesCondition($page, "!document.querySelector('[data-testid=\"post-composer-dialog\"]')"); + waitForTestIdGone($page, 'post-composer-dialog'); $page->assertMissing('@unsaved-changes-dialog') ->assertMissing('@post-composer-dialog') @@ -776,11 +767,11 @@ function seedUnsavedChangesReelWithBareTrack(): Post $this->actingAs($user); $page = visit(route('app.posts.edit', $post))->resize(1280, 900); - waitForUnsavedChangesCondition($page, "document.querySelector('[data-testid=\"channel-settings-rows\"] [role=\"combobox\"]:not([disabled])')?.textContent.includes('announcements')"); + waitForCondition($page, "document.querySelector('[data-testid=\"channel-settings-rows\"] [role=\"combobox\"]:not([disabled])')?.textContent.includes('announcements')"); expect($page->script("document.querySelector('[data-testid=\"channel-settings-rows\"] [role=\"combobox\"]').textContent.trim()"))->toBe('#announcements'); $page->click('@composer-save-draft'); - waitForUnsavedChangesCondition($page, "!document.querySelector('[data-testid=\"post-composer-dialog\"]')"); + waitForTestIdGone($page, 'post-composer-dialog'); $page->assertNoJavaScriptErrors(); expect($post->fresh()->meta)->toHaveKey('channel_id', '999') @@ -792,15 +783,15 @@ function seedUnsavedChangesReelWithBareTrack(): Post $page = openUnsavedChangesFromList($post); $page->click('@instagram-share-to-feed'); - waitForUnsavedChangesCondition($page, "document.querySelector('[data-testid=\"instagram-share-to-feed\"]')?.getAttribute('aria-checked') === 'false'"); + waitForTestIdState($page, 'instagram-share-to-feed', "element.getAttribute('aria-checked') === 'false'"); $page->back(); - waitForUnsavedChangesTestId($page, 'unsaved-changes-dialog'); + waitForTestId($page, 'unsaved-changes-dialog'); $page->back(); $page->script('new Promise((resolve) => setTimeout(resolve, 500))'); $page->assertVisible('@unsaved-changes-dialog') ->click('@unsaved-changes-keep'); - waitForUnsavedChangesCondition($page, "!document.querySelector('[data-testid=\"unsaved-changes-dialog\"]')"); + waitForTestIdGone($page, 'unsaved-changes-dialog'); $page->assertVisible('@post-composer-dialog') ->assertAttribute('@instagram-share-to-feed', 'aria-checked', 'false') @@ -811,13 +802,13 @@ function seedUnsavedChangesReelWithBareTrack(): Post $post = seedUnsavedChangesReel(); $page = visit(route('app.posts.edit', $post))->resize(1280, 900); - waitForUnsavedChangesTestId($page, 'instagram-share-to-feed'); + waitForTestId($page, 'instagram-share-to-feed'); $page->click('@instagram-share-to-feed'); - waitForUnsavedChangesCondition($page, "document.querySelector('[data-testid=\"instagram-share-to-feed\"]')?.getAttribute('aria-checked') === 'false'"); + waitForTestIdState($page, 'instagram-share-to-feed', "element.getAttribute('aria-checked') === 'false'"); $page->back(); - waitForUnsavedChangesTestId($page, 'unsaved-changes-discard'); + waitForTestId($page, 'unsaved-changes-discard'); $page->click('@unsaved-changes-discard'); - waitForUnsavedChangesCondition($page, "!document.querySelector('[data-testid=\"post-composer-dialog\"]')"); + waitForTestIdGone($page, 'post-composer-dialog'); $page->assertVisible('@sidebar-logo') ->assertMissing('@post-composer-dialog') @@ -831,7 +822,7 @@ function seedUnsavedChangesReelWithBareTrack(): Post $post = seedUnsavedChangesReel(); $page = visit(route('app.posts.edit', $post))->resize(1280, 900); - waitForUnsavedChangesTestId($page, 'instagram-share-to-feed'); + waitForTestId($page, 'instagram-share-to-feed'); $page->click('@instagram-share-to-feed'); $page->script("window.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', metaKey: true, bubbles: true, cancelable: true }))"); $page->assertMissing('@command-palette') @@ -856,7 +847,7 @@ function seedUnsavedChangesReelWithBareTrack(): Post $post = seedUnsavedChangesReel(); $page = visit(route('app.posts.edit', $post))->resize(1280, 900); - waitForUnsavedChangesTestId($page, 'instagram-share-to-feed'); + waitForTestId($page, 'instagram-share-to-feed'); $page->click('@instagram-share-to-feed'); $page->script(<<<'JS' (() => { @@ -867,7 +858,7 @@ function seedUnsavedChangesReelWithBareTrack(): Post outside.dispatchEvent(new MouseEvent('click', options)); })() JS); - waitForUnsavedChangesTestId($page, 'unsaved-changes-dialog'); + waitForTestId($page, 'unsaved-changes-dialog'); $page->assertVisible('@unsaved-changes-dialog') ->assertVisible('@post-composer-dialog') @@ -879,28 +870,28 @@ function seedUnsavedChangesReelWithBareTrack(): Post $page = openUnsavedChangesFromList($post); $page->click('@instagram-share-to-feed'); - waitForUnsavedChangesCondition($page, "document.querySelector('[data-testid=\"instagram-share-to-feed\"]')?.getAttribute('aria-checked') === 'false'"); + waitForTestIdState($page, 'instagram-share-to-feed', "element.getAttribute('aria-checked') === 'false'"); $arrived = Post::factory()->forAccount($post->socialAccount, ContentType::InstagramReel)->create([ 'user_id' => $post->user_id, 'content' => 'Arrived while editing', 'meta' => [], ]); $page->script("document.getElementById('app').__vue_app__.config.globalProperties.\$inertia.reload({ only: ['queue', 'posts', 'counts', 'hasData', 'channels'], reset: ['posts'] })"); - waitForUnsavedChangesCondition($page, "document.querySelector('[data-testid=\"post-edit-{$arrived->id}\"]')"); + waitForTestId($page, "post-edit-{$arrived->id}"); foreach (range(1, 2) as $attempt) { $page->back(); - waitForUnsavedChangesTestId($page, 'unsaved-changes-keep'); + waitForTestId($page, 'unsaved-changes-keep'); $page->click('@unsaved-changes-keep'); - waitForUnsavedChangesCondition($page, "!document.querySelector('[data-testid=\"unsaved-changes-dialog\"]')"); + waitForTestIdGone($page, 'unsaved-changes-dialog'); } $page->script("document.querySelector('[data-testid=\"sidebar-logo\"]').click()"); - waitForUnsavedChangesTestId($page, 'unsaved-changes-discard'); + waitForTestId($page, 'unsaved-changes-discard'); $page->click('@unsaved-changes-discard'); - waitForUnsavedChangesCondition($page, "!document.querySelector('[data-testid=\"post-composer-dialog\"]')"); + waitForTestIdGone($page, 'post-composer-dialog'); $page->back(); - waitForUnsavedChangesTestId($page, 'instagram-share-to-feed'); + waitForTestId($page, 'instagram-share-to-feed'); expect($page->script("Boolean(document.querySelector('[data-testid=\"post-edit-{$arrived->id}\"]'))"))->toBeTrue(); $page->assertNoJavaScriptErrors(); @@ -911,10 +902,10 @@ function seedUnsavedChangesReelWithBareTrack(): Post $page = openUnsavedChangesFromList($post, route('app.settings.preferences')); $page->click('@instagram-share-to-feed'); - waitForUnsavedChangesCondition($page, "document.querySelector('[data-testid=\"instagram-share-to-feed\"]')?.getAttribute('aria-checked') === 'false'"); + waitForTestIdState($page, 'instagram-share-to-feed', "element.getAttribute('aria-checked') === 'false'"); $length = $page->script('window.history.length'); $page->script('window.history.go(-2)'); - waitForUnsavedChangesCondition($page, "!document.querySelector('[data-testid=\"post-composer-dialog\"]')"); + waitForTestIdGone($page, 'post-composer-dialog'); $page->script('new Promise((resolve) => setTimeout(resolve, 800))'); $page->assertMissing('@post-composer-dialog') @@ -924,7 +915,7 @@ function seedUnsavedChangesReelWithBareTrack(): Post ->and($page->script('window.history.state !== null'))->toBeTrue(); $page->back(); - waitForUnsavedChangesCondition($page, "location.pathname === '".parse_url(route('app.settings.preferences'), PHP_URL_PATH)."'"); + waitForPath($page, parse_url(route('app.settings.preferences'), PHP_URL_PATH)); $page->assertPathIs((string) parse_url(route('app.settings.preferences'), PHP_URL_PATH)) ->assertNoJavaScriptErrors(); @@ -936,25 +927,25 @@ function seedUnsavedChangesReelWithBareTrack(): Post $page->type('@instagram-collaborator-input', 'bad-name!') ->click('@instagram-collaborator-add'); - waitForUnsavedChangesTestId($page, 'instagram-collaborators-notice'); + waitForTestId($page, 'instagram-collaborators-notice'); $page->click('@instagram-collaborators-notice-dismiss'); - waitForUnsavedChangesCondition($page, "!document.querySelector('[data-testid=\"instagram-collaborators-notice\"]')"); + waitForTestIdGone($page, 'instagram-collaborators-notice'); $page->click('@composer-save-draft'); - waitForUnsavedChangesCondition($page, 'document.body.textContent.includes('.json_encode(__('posts.form.instagram.collaborators.invalid')).')'); + waitForCondition($page, 'document.body.textContent.includes('.json_encode(__('posts.form.instagram.collaborators.invalid')).')'); $page->assertSee(__('posts.form.instagram.collaborators.invalid')) ->assertVisible('@post-composer-dialog') ->click('@composer-close'); - waitForUnsavedChangesTestId($page, 'unsaved-changes-keep'); + waitForTestId($page, 'unsaved-changes-keep'); $page->click('@unsaved-changes-keep'); - waitForUnsavedChangesCondition($page, "!document.querySelector('[data-testid=\"unsaved-changes-dialog\"]')"); + waitForTestIdGone($page, 'unsaved-changes-dialog'); $page->click('@instagram-collaborator-remove-bad-name!') ->type('@instagram-collaborator-input', 'alice') ->click('@instagram-collaborator-add'); - waitForUnsavedChangesTestId($page, 'instagram-collaborator-alice'); + waitForTestId($page, 'instagram-collaborator-alice'); $page->click('@composer-save-draft'); - waitForUnsavedChangesCondition($page, "!document.querySelector('[data-testid=\"post-composer-dialog\"]')"); + waitForTestIdGone($page, 'post-composer-dialog'); $page->back(); $page->script('new Promise((resolve) => setTimeout(resolve, 800))'); @@ -970,22 +961,23 @@ function seedUnsavedChangesReelWithBareTrack(): Post $page = openUnsavedChangesFromList($post, route('app.settings.preferences')); $page->click('@instagram-share-to-feed'); - waitForUnsavedChangesCondition($page, "document.querySelector('[data-testid=\"instagram-share-to-feed\"]')?.getAttribute('aria-checked') === 'false'"); + waitForTestIdState($page, 'instagram-share-to-feed', "element.getAttribute('aria-checked') === 'false'"); $page->back(); - waitForUnsavedChangesTestId($page, 'unsaved-changes-keep'); + waitForTestId($page, 'unsaved-changes-keep'); $page->click('@unsaved-changes-keep'); - waitForUnsavedChangesCondition($page, "!document.querySelector('[data-testid=\"unsaved-changes-dialog\"]')"); + waitForTestIdGone($page, 'unsaved-changes-dialog'); $page->click('@instagram-share-to-feed'); - waitForUnsavedChangesCondition($page, "document.querySelector('[data-testid=\"instagram-share-to-feed\"]')?.getAttribute('aria-checked') === 'true'"); + waitForTestIdState($page, 'instagram-share-to-feed', "element.getAttribute('aria-checked') === 'true'"); $page->script('new Promise((resolve) => setTimeout(resolve, 500))'); $page->click('@composer-close'); - waitForUnsavedChangesCondition($page, "!document.querySelector('[data-testid=\"post-composer-dialog\"]') && !location.search.includes('edit=')"); + waitForTestIdGone($page, 'post-composer-dialog'); + waitForCondition($page, "!location.search.includes('edit=')"); $page->assertMissing('@unsaved-changes-dialog') ->assertPathIs(unsavedChangesListPath()); $page->back(); - waitForUnsavedChangesCondition($page, "location.pathname === '".parse_url(route('app.settings.preferences'), PHP_URL_PATH)."'"); + waitForPath($page, parse_url(route('app.settings.preferences'), PHP_URL_PATH)); $page->assertPathIs((string) parse_url(route('app.settings.preferences'), PHP_URL_PATH)) ->assertNoJavaScriptErrors(); @@ -997,10 +989,11 @@ function seedUnsavedChangesReelWithBareTrack(): Post $page = openUnsavedChangesFromList($post, route('app.settings.preferences')); $page->click('@instagram-share-to-feed'); - waitForUnsavedChangesCondition($page, "document.querySelector('[data-testid=\"instagram-share-to-feed\"]')?.getAttribute('aria-checked') === 'false'"); + waitForTestIdState($page, 'instagram-share-to-feed', "element.getAttribute('aria-checked') === 'false'"); $post->forceFill(['status' => PostStatus::Published, 'published_at' => now()])->save(); $page->click('@composer-save-draft'); - waitForUnsavedChangesCondition($page, "!document.querySelector('[data-testid=\"post-composer-dialog\"]') && !location.search.includes('edit=')"); + waitForTestIdGone($page, 'post-composer-dialog'); + waitForCondition($page, "!location.search.includes('edit=')"); $page->back(); $page->script('new Promise((resolve) => setTimeout(resolve, 800))'); @@ -1042,7 +1035,7 @@ function seedUnsavedChangesReelWithBareTrack(): Post $this->actingAs($user); $page = visit(route('app.posts.index', ['tab' => 'drafts']))->resize(1280, 900); - waitForUnsavedChangesTestId($page, "post-edit-{$post->id}"); + waitForTestId($page, "post-edit-{$post->id}"); $page->script(<<<'JS' (() => { const open = XMLHttpRequest.prototype.open; @@ -1061,7 +1054,7 @@ function seedUnsavedChangesReelWithBareTrack(): Post })() JS); $page->click("@post-edit-{$post->id}"); - waitForUnsavedChangesTestId($page, 'composer-editor-column'); + waitForTestId($page, 'composer-editor-column'); $page->script(<<<'JS' (() => { const target = document.querySelector('[data-testid="composer-editor-column"] [contenteditable="true"]') ?? document.querySelector('[data-testid="composer-editor-column"]'); @@ -1071,9 +1064,9 @@ function seedUnsavedChangesReelWithBareTrack(): Post })() JS); expect($page->script("document.querySelector('[data-testid=\"tiktok-allow-comments\"]')?.disabled === true"))->toBeFalse(); - waitForUnsavedChangesCondition($page, "document.querySelector('[data-testid=\"tiktok-allow-comments\"]')?.disabled === true"); + waitForTestIdState($page, 'tiktok-allow-comments', 'element.disabled === true'); $page->click('@composer-close'); - waitForUnsavedChangesCondition($page, "!document.querySelector('[data-testid=\"post-composer-dialog\"]') || document.querySelector('[data-testid=\"unsaved-changes-dialog\"]')"); + waitForTestIdGoneOrShown($page, gone: 'post-composer-dialog', shown: 'unsaved-changes-dialog'); $page->assertMissing('@unsaved-changes-dialog') ->assertMissing('@post-composer-dialog') @@ -1121,13 +1114,13 @@ function seedUnsavedChangesReelWithBareTrack(): Post input.dispatchEvent(new Event('change', { bubbles: true })); })() JS); - waitForUnsavedChangesTestId($page, "composer-{$account->id}-upload-item"); + waitForTestId($page, "composer-{$account->id}-upload-item"); $page->click('@composer-close'); - waitForUnsavedChangesTestId($page, 'unsaved-changes-dialog'); + waitForTestId($page, 'unsaved-changes-dialog'); $page->assertVisible('@unsaved-changes-dialog') ->click('@unsaved-changes-discard'); - waitForUnsavedChangesCondition($page, "!document.querySelector('[data-testid=\"post-composer-dialog\"]')"); + waitForTestIdGone($page, 'post-composer-dialog'); $page->assertMissing('@post-composer-dialog') ->assertNoJavaScriptErrors(); @@ -1150,9 +1143,9 @@ function seedUnsavedChangesReelWithBareTrack(): Post $page->click("@composer-caption-{$account->id}") ->type("@composer-caption-{$account->id}", ' later') ->click('@composer-schedule-trigger'); - waitForUnsavedChangesTestId($page, 'composer-queue-manage-slots'); + waitForTestId($page, 'composer-queue-manage-slots'); $page->click('@composer-queue-manage-slots'); - waitForUnsavedChangesTestId($page, 'unsaved-changes-dialog'); + waitForTestId($page, 'unsaved-changes-dialog'); $page->assertVisible('@unsaved-changes-dialog') ->assertVisible('@post-composer-dialog') diff --git a/tests/Pest.php b/tests/Pest.php index 7c3ac67540..ff2dbe34c9 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -562,3 +562,84 @@ function waitForWebFonts(mixed $page): void })(); JS); } + +/** + * Wait until a JavaScript expression is true in the page, for up to 15 + * seconds. Assertions do not wait for the SPA to paint, and a blocking + * sleep() would starve the server that serves the page, so the page polls. + */ +function waitForCondition(mixed $page, string $condition): void +{ + $page->script(<< { + for (let i = 0; i < 300; i++) { + if ({$condition}) return; + await new Promise((resolve) => setTimeout(resolve, 50)); + } + })(); + JS); +} + +/** + * The JavaScript that finds the element with this data-testid. + */ +function testIdQuery(string $testId): string +{ + $selector = json_encode("[data-testid=\"{$testId}\"]"); + + return "document.querySelector({$selector})"; +} + +/** + * Wait until the element with this data-testid is mounted and laid out. + */ +function waitForTestId(mixed $page, string $testId): void +{ + $element = testIdQuery($testId); + + waitForCondition($page, "{$element}?.getBoundingClientRect().height > 0"); +} + +/** + * Wait until no element with this data-testid is left in the page. + */ +function waitForTestIdGone(mixed $page, string $testId): void +{ + $element = testIdQuery($testId); + + waitForCondition($page, "!{$element}"); +} + +/** + * Wait until the element with this data-testid exists and the expression, + * written about `element`, is true: "element.getAttribute('aria-checked') === 'true'". + */ +function waitForTestIdState(mixed $page, string $testId, string $expression): void +{ + $element = testIdQuery($testId); + + waitForCondition($page, "((element) => Boolean(element) && ({$expression}))({$element})"); +} + +/** + * Wait until one element is gone or another is shown, whichever happens + * first: a test can then assert the outcome it expects without waiting out + * the timeout when the other one happens. + */ +function waitForTestIdGoneOrShown(mixed $page, string $gone, string $shown): void +{ + $goneElement = testIdQuery($gone); + $shownElement = testIdQuery($shown); + + waitForCondition($page, "!{$goneElement} || {$shownElement}"); +} + +/** + * Wait until the page is at this path. + */ +function waitForPath(mixed $page, string $path): void +{ + $expected = json_encode($path); + + waitForCondition($page, "location.pathname === {$expected}"); +} From 3e6492d4ddda99511c724515c6c08d665e7f796f Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Fri, 16 Oct 2026 14:50:14 -0300 Subject: [PATCH 4/5] Take selectors in the shared browser wait helpers One set of helpers covers test ids, roles and nested selectors: @name stands for a test id anywhere in the selector, as in $page->click('@name'). waitForText replaces the textContent waits, and the per-file closed-menu helper is gone. --- AGENTS.md | 14 +- tests/Browser/ComposerChannelSettingsTest.php | 354 +++++++++--------- tests/Browser/ComposerUnsavedChangesTest.php | 258 ++++++------- tests/Pest.php | 61 +-- 4 files changed, 343 insertions(+), 344 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index d2d31fc059..4d13bab541 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1026,13 +1026,15 @@ Browser tests live in `tests/Browser` and run on `pestphp/pest-plugin-browser` d - `@my-element` resolves to `[data-testid="my-element"]`, so add `data-testid="my-element"` in the Vue component and use `$page->click('@my-element')`. - Bind it for repeated elements: `:data-testid="`connect-${platform.value}`"`. - Assertions do NOT auto-wait on SPA paint (`assertMissing`/`assertVisible` check once; only actions like `click()` wait). **Every wait goes through the shared helpers in `tests/Pest.php`** (owner decision October 2026), never through a helper of your own: - - `waitForTestId($page, 'id')`: the element is mounted and laid out. - - `waitForTestIdGone($page, 'id')`: no element with that test id is left. - - `waitForTestIdState($page, 'id', "element.getAttribute('aria-checked') === 'true'")`: the element exists and the expression about `element` is true (attributes, text, `dataset`, a child). - - `waitForTestIdGoneOrShown($page, gone: 'post-composer-dialog', shown: 'unsaved-changes-dialog')`: whichever happens first, so the assertion after it fails at once instead of waiting out the timeout. + - Every helper takes a CSS selector where `@name` stands for `[data-testid="name"]`, as in `$page->click('@name')`, anywhere in it: `'@channel-settings-rows [role="combobox"]'`, `'@channel-settings-row:has(@instagram-collaborators)'`. Prefer `@` test ids; reach for a `role` or `data-slot` only when the element has no test id, and never a CSS class. + - `waitFor($page, '@id')`: an element is mounted and laid out. + - `waitForGone($page, '@id')`: no element matches. + - `waitForState($page, '@id', "element.getAttribute('aria-checked') === 'true'")`: an element exists and the expression about `element` is true (attributes, `dataset`, `disabled`, position). + - `waitForText($page, '[role="option"]', 'general')`: any matching element contains the text. + - `waitForGoneOrShown($page, gone: '@post-composer-dialog', shown: '@unsaved-changes-dialog')`: whichever happens first, so the assertion after it fails at once instead of waiting out the timeout. - `waitForPath($page, $path)`: the page is at that path. - - `waitForCondition($page, $js)`: only for what no test id can express (a `role`, `location.search`, a `window.__` spy). - - Never write `document.querySelector('[data-testid=…]')` inside a wait, and never define a per-file `waitFor*` helper. Pest's `Webpage` is `final readonly` and not macroable, which is why these are global functions. Files that still have their own `waitFor*` helpers predate this rule; move them to the shared helpers when touching them, and the sweep is tracked in #435. If a wait needs something the helpers cannot express, add a helper to `tests/Pest.php` instead of inlining it. + - `waitForCondition($page, $js)`: only for what is not about an element (`location.search`, a `window.__` spy). + - Never write `document.querySelector(...)` inside a wait, and never define a per-file `waitFor*` helper. Pest's `Webpage` is `final readonly` and not macroable, which is why these are global functions. Files that still have their own `waitFor*` helpers predate this rule; move them to the shared helpers when touching them (the sweep is tracked in #435). If a wait needs something the helpers cannot express, add a helper to `tests/Pest.php` instead of inlining it. - **Never `sleep()` in a browser test.** The HTTP server that serves the page runs inside the same PHP process (an Amp loop that only ticks while Pest awaits Playwright), so a blocking `sleep()` starves every asset request: the page stays blank, the Vue app never mounts, and screenshots come out empty. The helpers above poll from the page with `$page->script(...)`, which keeps the loop running. - `BrowserTestCase` sets `$fakesVite = false` on purpose: these tests load real built assets, so faking Vite blanks the app. - End page assertions with `->assertNoJavaScriptErrors()`. diff --git a/tests/Browser/ComposerChannelSettingsTest.php b/tests/Browser/ComposerChannelSettingsTest.php index 1765d87770..4037e01caa 100644 --- a/tests/Browser/ComposerChannelSettingsTest.php +++ b/tests/Browser/ComposerChannelSettingsTest.php @@ -52,11 +52,6 @@ function channelSettingsEventDay(int $daysAfterStart): string return now()->addYear()->startOfDay()->addDays($daysAfterStart)->toDateString(); } -function waitForChannelSettingsClosedMenu(mixed $page): void -{ - waitForCondition($page, "!document.querySelector('[role=\"listbox\"]')"); -} - function fakeChannelSettingsApis(): void { $boards = [ @@ -86,7 +81,7 @@ function fakeChannelSettingsApis(): void $post = seedChannelSettingsPost($platform, $contentType, $media); $page = visit(route('app.posts.edit', $post))->resize(1280, 900); - waitForTestId($page, 'channel-settings-rows'); + waitFor($page, '@channel-settings-rows'); $layout = $page->script(<<<'JS' (() => { @@ -144,7 +139,7 @@ function fakeChannelSettingsApis(): void $id = $post->social_account_id; $page = visit(route('app.posts.edit', $post))->resize(1280, 900); - waitForTestId($page, "composer-type-{$id}-instagram_feed"); + waitFor($page, "@composer-type-{$id}-instagram_feed"); $page->assertAttribute("@composer-type-{$id}-instagram_feed", 'aria-checked', 'true') ->assertAttribute("@composer-type-{$id}-instagram_reel", 'aria-checked', 'false') @@ -160,7 +155,7 @@ function fakeChannelSettingsApis(): void JS))->toBeTrue(); $page->click("@composer-type-{$id}-instagram_reel"); - waitForTestIdState($page, "composer-type-{$id}-instagram_reel", "element.getAttribute('aria-checked') === 'true'"); + waitForState($page, "@composer-type-{$id}-instagram_reel", "element.getAttribute('aria-checked') === 'true'"); $page->assertAttribute("@composer-type-{$id}-instagram_reel", 'aria-checked', 'true') ->assertNoJavaScriptErrors(); @@ -176,7 +171,7 @@ function fakeChannelSettingsApis(): void $post->update(['media' => $images->map(fn (Media $media): array => MediaItem::fromMedia($media)->toArray())->all()]); $page = visit(route('app.posts.edit', $post))->resize(1280, 900); - waitForTestId($page, 'pinterest-board-trigger'); + waitFor($page, '@pinterest-board-trigger'); $page->assertMissing("@composer-type-{$id}") ->assertNoJavaScriptErrors() @@ -191,27 +186,27 @@ function fakeChannelSettingsApis(): void $post = seedChannelSettingsPost(Platform::Pinterest, ContentType::PinterestPin); $page = visit(route('app.posts.edit', $post))->resize(1280, 900); - waitForTestId($page, 'pinterest-board-trigger'); + waitFor($page, '@pinterest-board-trigger'); $page->click('@pinterest-board-trigger'); - waitForTestId($page, 'pinterest-board-option-board_1'); + waitFor($page, '@pinterest-board-option-board_1'); $page->assertSeeIn('@pinterest-board-picker', '@trypostit') ->assertVisible('@pinterest-board-option-board_2'); expect($page->script("!!document.querySelector('[data-testid=\"pinterest-board-option-board_1\"] img')"))->toBeTrue(); $page->fill('@pinterest-board-search', 'DÍS'); - waitForTestIdGone($page, 'pinterest-board-option-board_2'); + waitForGone($page, '@pinterest-board-option-board_2'); $page->assertMissing('@pinterest-board-option-board_2') ->assertVisible('@pinterest-board-option-board_1') ->fill('@pinterest-board-search', ''); $page->click('@pinterest-boards-refresh'); - waitForTestId($page, 'pinterest-board-option-board_3'); + waitFor($page, '@pinterest-board-option-board_3'); $page->assertSeeIn('@pinterest-board-option-board_3', 'Universal'); $page->fill('@pinterest-board-new-name', 'Recipes') ->click('@pinterest-board-create'); - waitForTestId($page, 'pinterest-board-selected'); + waitFor($page, '@pinterest-board-selected'); $page->assertSeeIn('@pinterest-board-selected', 'Recipes') ->assertMissing('@pinterest-board-picker') ->assertNoJavaScriptErrors(); @@ -221,15 +216,15 @@ function fakeChannelSettingsApis(): void && $request->data() === ['name' => 'Recipes']); $page->click('@pinterest-board-trigger'); - waitForTestId($page, 'pinterest-board-new-name'); + waitFor($page, '@pinterest-board-new-name'); expect($page->script("document.querySelector('[data-testid=\"pinterest-board-new-name\"]').value"))->toBe(''); $page->click('@pinterest-boards-refresh'); - waitForTestIdGone($page, 'pinterest-board-option-board_new'); + waitForGone($page, '@pinterest-board-option-board_new'); $page->assertMissing('@pinterest-board-option-board_new') ->assertVisible('@pinterest-board-option-board_3'); $page->click('@pinterest-board-option-board_3'); - waitForTestIdGone($page, 'pinterest-board-picker'); + waitForGone($page, '@pinterest-board-picker'); $page->click('@composer-save-draft')->assertMissing('@post-composer-dialog'); expect(data_get($post->fresh()->meta, 'board_id'))->toBe('board_3'); @@ -240,7 +235,7 @@ function fakeChannelSettingsApis(): void $post = seedChannelSettingsPost(Platform::GoogleBusiness, ContentType::GoogleBusinessPost); $page = visit(route('app.posts.edit', $post))->resize(1280, 900); - waitForTestId($page, 'google-business-topic-STANDARD'); + waitFor($page, '@google-business-topic-STANDARD'); $fields = <<<'JS' [...document.querySelectorAll('[data-testid="composer-customization"] [data-testid^="google-business-"]')] .map((node) => node.dataset.testid) @@ -254,29 +249,29 @@ function fakeChannelSettingsApis(): void ->and($page->script($fields))->toBe(['google-business-cta']); $page->click('@google-business-topic-OFFER'); - waitForTestId($page, 'google-business-add-details'); + waitFor($page, '@google-business-add-details'); expect($page->script($fields))->toBe(['google-business-title', 'google-business-start', 'google-business-end', 'google-business-add-details']) ->and($page->script("document.querySelector('[data-testid=\"google-business-add-details\"]').getAttribute('aria-checked')"))->toBe('false'); $page->click('@google-business-topic-STANDARD'); - waitForTestId($page, 'google-business-cta'); + waitFor($page, '@google-business-cta'); $page->click('@google-business-cta'); - waitForTestId($page, 'google-business-cta-option-BOOK'); + waitFor($page, '@google-business-cta-option-BOOK'); $page->click('@google-business-cta-option-BOOK'); - waitForTestId($page, 'google-business-cta-url'); + waitFor($page, '@google-business-cta-url'); $page->fill('@google-business-cta-url', 'https://trypost.it/book') ->click('@google-business-topic-OFFER'); - waitForTestId($page, 'google-business-redeem'); + waitFor($page, '@google-business-redeem'); expect($page->script($fields))->toBe(['google-business-title', 'google-business-start', 'google-business-end', 'google-business-add-details', 'google-business-coupon', 'google-business-redeem', 'google-business-terms']); $page->assertValue('@google-business-redeem', 'https://trypost.it/book') ->click('@google-business-add-details'); - waitForTestIdGone($page, 'google-business-coupon'); + waitForGone($page, '@google-business-coupon'); $page->assertMissing('@google-business-redeem'); $page->click('@google-business-topic-EVENT'); - waitForTestId($page, 'google-business-add-time'); + waitFor($page, '@google-business-add-time'); expect($page->script($fields))->toBe(['google-business-title', 'google-business-add-time', 'google-business-start', 'google-business-end', 'google-business-cta']); @@ -300,14 +295,14 @@ function fakeChannelSettingsApis(): void $post = seedChannelSettingsPost(Platform::GoogleBusiness, ContentType::GoogleBusinessPost); $page = visit(route('app.posts.edit', $post))->resize(1280, 900); - waitForTestId($page, 'google-business-cta'); + waitFor($page, '@google-business-cta'); $page->click('@google-business-cta'); - waitForTestId($page, 'google-business-cta-option-BOOK'); + waitFor($page, '@google-business-cta-option-BOOK'); $page->click('@google-business-cta-option-BOOK'); - waitForTestId($page, 'google-business-cta-url'); + waitFor($page, '@google-business-cta-url'); $page->fill('@google-business-cta-url', 'not a link') ->click('@composer-save-draft'); - waitForTestIdState($page, 'google-business-cta-url', "element.getAttribute('aria-invalid') === 'true'"); + waitForState($page, '@google-business-cta-url', "element.getAttribute('aria-invalid') === 'true'"); expect($page->script("document.querySelector('[data-testid=\"google-business-cta-url\"]').getAttribute('aria-invalid')"))->toBe('true') ->and($page->script("document.querySelector('[data-testid=\"google-business-cta-url\"]').closest('[data-testid=\"channel-settings-row\"]').textContent")) @@ -323,11 +318,11 @@ function fakeChannelSettingsApis(): void $post->update(['meta' => ['topic_type' => 'EVENT', 'event' => ['title' => 'Launch night', 'start_date' => channelSettingsEventDay(0), 'end_date' => channelSettingsEventDay(2)]]]); $page = visit(route('app.posts.edit', $post))->resize(1280, 900); - waitForTestId($page, 'google-business-add-time'); + waitFor($page, '@google-business-add-time'); $page->assertMissing('@google-business-start-time') ->assertMissing('@google-business-end-time') ->click('@google-business-add-time'); - waitForTestId($page, 'google-business-start-time'); + waitFor($page, '@google-business-start-time'); $layout = $page->script(<<<'JS' (() => { @@ -363,7 +358,7 @@ function fakeChannelSettingsApis(): void $post = seedChannelSettingsPost(Platform::YouTube, ContentType::YouTubeShort); $page = visit(route('app.posts.edit', $post))->resize(1280, 900); - waitForTestId($page, 'youtube-title'); + waitFor($page, '@youtube-title'); $order = ['youtube-title', 'youtube-category', 'youtube-privacy', 'youtube-license', 'youtube-notify-subscribers', 'youtube-embeddable', 'youtube-made-for-kids', 'youtube-ai-generated']; @@ -384,17 +379,17 @@ function fakeChannelSettingsApis(): void ->assertValue('@youtube-title', 'Channel settings') ->fill('@youtube-title', 'Explicit title') ->click('@youtube-category'); - waitForTestId($page, 'youtube-category-27'); + waitFor($page, '@youtube-category-27'); $page->click('@youtube-category-27'); - waitForChannelSettingsClosedMenu($page); + waitForGone($page, '[role="listbox"]'); $page->click('@youtube-privacy'); - waitForTestId($page, 'youtube-privacy-unlisted'); + waitFor($page, '@youtube-privacy-unlisted'); $page->click('@youtube-privacy-unlisted'); - waitForChannelSettingsClosedMenu($page); + waitForGone($page, '[role="listbox"]'); $page->click('@youtube-license'); - waitForTestId($page, 'youtube-license-creativeCommon'); + waitFor($page, '@youtube-license-creativeCommon'); $page->click('@youtube-license-creativeCommon'); - waitForChannelSettingsClosedMenu($page); + waitForGone($page, '[role="listbox"]'); $page->click('@youtube-notify-subscribers') ->click('@youtube-made-for-kids') ->click('@youtube-ai-generated') @@ -419,7 +414,7 @@ function fakeChannelSettingsApis(): void $post = seedChannelSettingsPost(Platform::TikTok, ContentType::TikTokVideo); $page = visit(route('app.posts.edit', $post))->resize(1280, 900); - waitForTestId($page, 'tiktok-ai-generated'); + waitFor($page, '@tiktok-ai-generated'); expect($page->script(<<<'JS' (() => { @@ -447,16 +442,16 @@ function fakeChannelSettingsApis(): void expect($today->format('Y-m-d'))->not->toBe(now('UTC')->format('Y-m-d')); $page = visit(route('app.posts.edit', $post))->withTimezone('UTC')->resize(1280, 900); - waitForTestId($page, 'google-business-topic-EVENT'); + waitFor($page, '@google-business-topic-EVENT'); $page->click('@google-business-topic-EVENT'); - waitForTestId($page, 'google-business-add-time'); + waitFor($page, '@google-business-add-time'); $page->fill('@google-business-title', 'Launch night') ->click('@google-business-add-time'); - waitForTestId($page, 'google-business-start-time'); + waitFor($page, '@google-business-start-time'); $page->fill('@google-business-start-time', '18:30') ->fill('@google-business-end-time', '21:00') ->click('@google-business-topic-OFFER'); - waitForTestId($page, 'google-business-add-details'); + waitFor($page, '@google-business-add-details'); $page->assertMissing('@google-business-start-time') ->assertNoJavaScriptErrors() @@ -480,7 +475,7 @@ function fakeChannelSettingsApis(): void $id = $post->social_account_id; $page = visit(route('app.posts.edit', $post))->resize(1280, 900); - waitForTestId($page, 'instagram-share-to-feed'); + waitFor($page, '@instagram-share-to-feed'); expect($page->script("document.querySelector('[data-testid=\"instagram-share-to-feed\"]').getAttribute('aria-checked')"))->toBe('true') ->and($page->script(<<<'JS' @@ -503,13 +498,13 @@ function fakeChannelSettingsApis(): void $page->click('@instagram-share-to-feed') ->click('@instagram-ai-generated') ->click("@composer-type-{$id}-instagram_story"); - waitForTestIdGone($page, 'instagram-share-to-feed'); - waitForTestId($page, 'instagram-ai-generated'); + waitForGone($page, '@instagram-share-to-feed'); + waitFor($page, '@instagram-ai-generated'); $page->click("@composer-type-{$id}-instagram_feed"); - waitForTestId($page, 'instagram-ai-generated'); + waitFor($page, '@instagram-ai-generated'); $page->assertMissing('@instagram-share-to-feed') ->click("@composer-type-{$id}-instagram_reel"); - waitForTestId($page, 'instagram-share-to-feed'); + waitFor($page, '@instagram-share-to-feed'); $page->assertNoJavaScriptErrors(); $page->click('@composer-save-draft')->assertMissing('@post-composer-dialog'); @@ -525,39 +520,39 @@ function fakeChannelSettingsApis(): void $post = seedChannelSettingsPost(Platform::Instagram, ContentType::InstagramReel); $page = visit(route('app.posts.edit', $post))->resize(1280, 900); - waitForTestId($page, 'instagram-music-add'); + waitFor($page, '@instagram-music-add'); $page->click('@instagram-music-add'); - waitForTestId($page, 'instagram-music-link-facebook'); + waitFor($page, '@instagram-music-link-facebook'); $page->assertPresent('@instagram-music-save-and-link') ->click('@instagram-music-maybe-later'); - waitForTestIdGone($page, 'instagram-music-link-facebook'); + waitForGone($page, '@instagram-music-link-facebook'); $page->type('@instagram-collaborator-input', '@alice') ->keys('@instagram-collaborator-input', 'Enter'); - waitForTestId($page, 'instagram-collaborators-notice'); + waitFor($page, '@instagram-collaborators-notice'); $page->assertPresent('@instagram-collaborator-alice') ->assertPresent('@instagram-trial-reel-unavailable') ->click('@instagram-collaborators-notice-dismiss'); - waitForTestIdGone($page, 'instagram-collaborators-notice'); + waitForGone($page, '@instagram-collaborators-notice'); $page->type('@instagram-collaborator-input', 'bob') ->click('@instagram-collaborator-add'); - waitForTestId($page, 'instagram-collaborator-bob'); + waitFor($page, '@instagram-collaborator-bob'); $page->click('@instagram-collaborator-remove-alice') ->click('@instagram-collaborator-remove-bob'); - waitForTestId($page, 'instagram-trial-reel-MANUAL'); + waitFor($page, '@instagram-trial-reel-MANUAL'); $page->click('@instagram-trial-reel-MANUAL'); - waitForTestId($page, 'instagram-trial-reel-check'); + waitFor($page, '@instagram-trial-reel-check'); $page->assertVisible('@instagram-trial-reel-illustration') ->click('@instagram-trial-reel-check-no'); - waitForTestIdGone($page, 'instagram-trial-reel-check'); + waitForGone($page, '@instagram-trial-reel-check'); $page->assertAttribute('@instagram-trial-reel-off', 'aria-checked', 'true') ->click('@instagram-trial-reel-MANUAL'); - waitForTestId($page, 'instagram-trial-reel-check'); + waitFor($page, '@instagram-trial-reel-check'); $page->click('@instagram-trial-reel-check-yes'); - waitForTestIdState($page, 'instagram-trial-reel-MANUAL', "element.getAttribute('aria-checked') === 'true'"); + waitForState($page, '@instagram-trial-reel-MANUAL', "element.getAttribute('aria-checked') === 'true'"); $page->assertNoJavaScriptErrors(); $page->click('@composer-save-draft')->assertMissing('@post-composer-dialog'); @@ -573,12 +568,12 @@ function fakeChannelSettingsApis(): void $post->update(['status' => Status::Scheduled, 'scheduled_at' => now()->addDay()]); $page = visit(route('app.posts.edit', $post))->resize(1280, 900); - waitForTestId($page, 'instagram-music-add'); + waitFor($page, '@instagram-music-add'); $page->click('@instagram-music-add'); - waitForTestId($page, 'instagram-music-save-and-link'); + waitFor($page, '@instagram-music-save-and-link'); $page->click('@instagram-music-save-and-link'); - waitForTestId($page, 'instagram-facebook-requirements'); + waitFor($page, '@instagram-facebook-requirements'); $page->assertMissing('@post-composer-dialog') ->assertPresent('@instagram-facebook-requirements-connect') @@ -604,18 +599,18 @@ function fakeChannelSettingsApis(): void $post->update(['status' => Status::Scheduled, 'scheduled_at' => now()->addDay()]); $page = visit(route('app.posts.edit', $post))->resize(1280, 900); - waitForTestId($page, 'composer-schedule-trigger'); + waitFor($page, '@composer-schedule-trigger'); $page->click('@composer-schedule-trigger'); - waitForTestId($page, 'composer-schedule-more-actions'); + waitFor($page, '@composer-schedule-more-actions'); $page->click('@composer-schedule-more-actions'); - waitForTestId($page, 'composer-schedule-now'); + waitFor($page, '@composer-schedule-now'); $page->click('@composer-schedule-now'); - waitForTestIdState($page, 'composer-submit', "element.dataset.scheduleMode === 'now'"); + waitForState($page, '@composer-submit', "element.dataset.scheduleMode === 'now'"); $page->click('@instagram-music-add'); - waitForTestId($page, 'instagram-music-save-and-link'); + waitFor($page, '@instagram-music-save-and-link'); $page->click('@instagram-music-save-and-link'); - waitForTestId($page, 'instagram-facebook-requirements'); + waitFor($page, '@instagram-facebook-requirements'); $page->assertNoJavaScriptErrors(); expect($post->fresh()->status)->toBe(Status::Draft); @@ -627,12 +622,12 @@ function fakeChannelSettingsApis(): void $post->update(['status' => Status::Scheduled, 'scheduled_at' => now()->addDay()]); $page = visit(route('app.posts.edit', $post))->resize(1280, 900); - waitForTestId($page, 'instagram-music-add'); + waitFor($page, '@instagram-music-add'); $page->click('@instagram-music-add'); - waitForTestId($page, 'instagram-music-save-and-link'); + waitFor($page, '@instagram-music-save-and-link'); $page->click('@instagram-music-save-and-link'); - waitForTestId($page, 'instagram-music-save-and-link-blocked'); + waitFor($page, '@instagram-music-save-and-link-blocked'); $page->assertSee(__('posts.form.instagram.music.save_and_link_blocked')) ->assertVisible('@post-composer-dialog') @@ -649,11 +644,11 @@ function fakeChannelSettingsApis(): void $post->update(['status' => Status::PendingApproval, 'scheduled_at' => now()->addDay()]); $page = visit(route('app.posts.edit', $post))->resize(1280, 900); - waitForTestId($page, 'instagram-music-add'); + waitFor($page, '@instagram-music-add'); $page->click('@instagram-music-add'); - waitForTestId($page, 'instagram-music-save-and-link'); + waitFor($page, '@instagram-music-save-and-link'); $page->click('@instagram-music-save-and-link'); - waitForTestId($page, 'instagram-facebook-requirements'); + waitFor($page, '@instagram-facebook-requirements'); $page->assertNoJavaScriptErrors(); expect($post->fresh()->status)->toBe(Status::PendingApproval); @@ -666,11 +661,11 @@ function fakeChannelSettingsApis(): void $post->update(['status' => Status::PendingApproval, 'scheduled_at' => now()->addDay()]); $page = visit(route('app.posts.edit', $post))->resize(1280, 900); - waitForTestId($page, 'instagram-music-add'); + waitFor($page, '@instagram-music-add'); $page->click('@instagram-music-add'); - waitForTestId($page, 'instagram-music-save-and-link'); + waitFor($page, '@instagram-music-save-and-link'); $page->click('@instagram-music-save-and-link'); - waitForTestId($page, 'instagram-music-save-and-link-pending'); + waitFor($page, '@instagram-music-save-and-link-pending'); $page->assertSee(__('posts.form.instagram.music.save_and_link_pending')) ->assertVisible('@post-composer-dialog') @@ -683,12 +678,12 @@ function fakeChannelSettingsApis(): void $post = seedChannelSettingsPost(Platform::Instagram, ContentType::InstagramReel); $page = visit(route('app.posts.edit', $post))->resize(1280, 900); - waitForTestId($page, 'instagram-music-add'); + waitFor($page, '@instagram-music-add'); $page->click('@instagram-music-add'); - waitForTestId($page, 'instagram-music-save-and-link'); + waitFor($page, '@instagram-music-save-and-link'); $page->click('@instagram-music-save-and-link'); - waitForTestId($page, 'instagram-facebook-requirements'); + waitFor($page, '@instagram-facebook-requirements'); $page->assertMissing('@post-composer-dialog')->assertNoJavaScriptErrors(); expect($post->fresh()->status)->toBe(Status::Draft); @@ -699,19 +694,18 @@ function fakeChannelSettingsApis(): void $post = seedChannelSettingsPost(Platform::Instagram, ContentType::InstagramReel); $page = visit(route('app.posts.edit', $post))->resize(1280, 900); - waitForTestId($page, 'instagram-collaborator-input'); + waitFor($page, '@instagram-collaborator-input'); $page->type('@instagram-collaborator-input', 'good_user') ->keys('@instagram-collaborator-input', 'Enter'); - waitForTestId($page, 'instagram-collaborators-notice'); + waitFor($page, '@instagram-collaborators-notice'); $page->click('@instagram-collaborators-notice-dismiss') ->type('@instagram-collaborator-input', 'bad-name!') ->click('@instagram-collaborator-add'); - waitForTestId($page, 'instagram-collaborator-bad-name!'); + waitFor($page, '[data-testid="instagram-collaborator-bad-name!"]'); $page->click('@composer-save-draft'); - $invalid = json_encode(__('posts.form.instagram.collaborators.invalid')); - waitForTestIdState($page, 'instagram-collaborators', "element.closest('[data-testid=\"channel-settings-row\"]')?.textContent.includes({$invalid})"); + waitForText($page, '@channel-settings-row:has(@instagram-collaborators)', __('posts.form.instagram.collaborators.invalid')); $page->assertSee(__('posts.form.instagram.collaborators.invalid'))->assertNoJavaScriptErrors(); expect(data_get($post->fresh()->meta, 'collaborators'))->toBeNull(); @@ -735,8 +729,8 @@ function fakeChannelSettingsApis(): void ]]]); $page = visit(route('app.posts.edit', $post))->resize(1280, 900); - waitForTestId($page, 'instagram-music-selected'); - waitForTestIdState($page, 'instagram-music-selected-cover', "element.getAttribute('src')?.startsWith('data:image/gif')"); + waitFor($page, '@instagram-music-selected'); + waitForState($page, '@instagram-music-selected-cover', "element.getAttribute('src')?.startsWith('data:image/gif')"); expect($page->script("document.querySelector('[data-testid=\"instagram-music-selected-cover\"]')?.getAttribute('src')"))->toBe($freshCover); $page->assertNoJavaScriptErrors(); @@ -754,8 +748,8 @@ function fakeChannelSettingsApis(): void ]]]); $page = visit(route('app.posts.edit', $post))->resize(1280, 900); - waitForTestId($page, 'instagram-music-selected'); - waitForTestIdGone($page, 'instagram-music-selected-cover'); + waitFor($page, '@instagram-music-selected'); + waitForGone($page, '@instagram-music-selected-cover'); $page->assertMissing('@instagram-music-selected-cover') ->assertSeeIn('@instagram-music-selected', 'Summer Song'); @@ -773,7 +767,7 @@ function fakeChannelSettingsApis(): void $post = seedChannelSettingsPost(Platform::InstagramFacebook, ContentType::InstagramReel); $page = visit(route('app.posts.edit', $post))->resize(1280, 900); - waitForTestId($page, 'instagram-music-add'); + waitFor($page, '@instagram-music-add'); $page->script(<<<'JS' (() => { window.__trackLookups = 0; @@ -786,16 +780,16 @@ function fakeChannelSettingsApis(): void JS); $page->click('@instagram-music-add'); - waitForTestId($page, 'instagram-music-track-1234567890'); + waitFor($page, '@instagram-music-track-1234567890'); $page->click('@instagram-music-track-1234567890'); - waitForTestId($page, 'instagram-music-selected'); + waitFor($page, '@instagram-music-selected'); $page->assertSeeIn('@instagram-music-selected', 'Summer Song') ->assertSeeIn('@instagram-preview-audio', 'Summer Song') ->assertPresent('@instagram-music-part-hint') ->assertNoJavaScriptErrors(); $page->click('@instagram-music-volume'); - waitForTestId($page, 'instagram-music-video_volume'); + waitFor($page, '@instagram-music-video_volume'); $page->script(<<<'JS' (() => { const slider = document.querySelector('[data-testid="instagram-music-video_volume"]'); @@ -803,10 +797,10 @@ function fakeChannelSettingsApis(): void slider.dispatchEvent(new Event('input', { bubbles: true })); })() JS); - waitForTestIdState($page, 'instagram-music-volume-panel', "element.textContent.includes('30%')"); + waitForText($page, '@instagram-music-volume-panel', '30%'); $page->assertSeeIn('@instagram-music-volume-panel', '30%') ->keys('@instagram-music-video_volume', 'Escape'); - waitForTestIdGone($page, 'instagram-music-volume-panel'); + waitForGone($page, '@instagram-music-volume-panel'); $page->click('@composer-save-draft')->assertMissing('@post-composer-dialog'); @@ -826,7 +820,7 @@ function fakeChannelSettingsApis(): void $id = $post->social_account_id; $page = visit(route('app.posts.edit', $post))->resize(1280, 900); - waitForTestId($page, "composer-hashtags-remaining-{$id}"); + waitFor($page, "@composer-hashtags-remaining-{$id}"); expect($page->script(<< { @@ -843,12 +837,12 @@ function fakeChannelSettingsApis(): void ->assertSeeIn("@composer-hashtags-remaining-{$id}", '2') ->assertMissing("@composer-hashtag-warning-{$id}") ->fill("@composer-caption-{$id}", 'Launch #a #b #c #d #e #f'); - waitForTestId($page, "composer-hashtag-warning-{$id}"); + waitFor($page, "@composer-hashtag-warning-{$id}"); $page->assertMissing("@composer-hashtags-remaining-{$id}") ->assertSeeIn("@composer-hashtag-warning-{$id}", 'Instagram only allows 5 hashtags per post.') ->fill("@composer-caption-{$id}", 'Launch #a #b #c #d #e'); - waitForTestIdGone($page, "composer-hashtag-warning-{$id}"); + waitForGone($page, "@composer-hashtag-warning-{$id}"); $page->assertSeeIn("@composer-hashtags-remaining-{$id}", '0') ->assertNoJavaScriptErrors(); @@ -861,7 +855,7 @@ function fakeChannelSettingsApis(): void $limit = Platform::Mastodon->maxContentLength(); $page = visit(route('app.posts.edit', $post))->resize(1280, 900); - waitForTestId($page, 'mastodon-content-warning'); + waitFor($page, '@mastodon-content-warning'); $page->assertSeeIn("@composer-char-count-{$id}", (string) ($limit - mb_strlen('Channel settings'))) ->fill('@mastodon-content-warning', 'Spoilers') @@ -880,19 +874,19 @@ function fakeChannelSettingsApis(): void $id = $post->social_account_id; $page = visit(route('app.posts.edit', $post))->resize(1280, 900); - waitForTestId($page, 'threads-topic-tag'); + waitFor($page, '@threads-topic-tag'); $page->fill('@threads-topic-tag', '#laravel') ->assertPresent("@composer-{$id}-dropzone") ->click("@composer-type-{$id}-threads_ghost_post"); - waitForTestIdGone($page, 'threads-topic-tag'); + waitForGone($page, '@threads-topic-tag'); $page->assertMissing("@composer-{$id}-dropzone") ->assertMissing("@composer-{$id}-media-source-menu") ->assertVisible("@composer-{$id}-media-source-disabled") ->hover("@composer-{$id}-media-source-disabled"); - waitForCondition($page, 'document.body.innerText.includes('.json_encode(__('posts.composer.media_not_supported')).')'); + waitForText($page, '[data-slot="tooltip-content"]', __('posts.composer.media_not_supported')); $page->click("@composer-type-{$id}-threads_post"); - waitForTestId($page, 'threads-topic-tag'); + waitFor($page, '@threads-topic-tag'); $page->assertVisible("@composer-{$id}-media-source-menu") ->assertMissing("@composer-{$id}-media-source-disabled") ->assertNoJavaScriptErrors(); @@ -914,12 +908,12 @@ function fakeChannelSettingsApis(): void $post->update(['content' => "Read {$url}"]); $page = visit(route('app.posts.edit', $post))->resize(1280, 900); - waitForTestId($page, "composer-link-card-{$id}"); + waitFor($page, "@composer-link-card-{$id}"); expect($page->script("document.querySelector('[data-testid=\"{$ghost}\"]').disabled"))->toBeFalse(); $page->click("@{$ghost}"); - waitForTestIdGone($page, "composer-link-card-{$id}"); + waitForGone($page, "@composer-link-card-{$id}"); $page->assertMissing("@composer-text-only-warning-{$id}") ->assertNoJavaScriptErrors(); @@ -935,25 +929,24 @@ function fakeChannelSettingsApis(): void $limit = Platform::Bluesky->maxContentLength(); $page = visit(route('app.posts.edit', $post))->resize(1280, 900); - waitForTestId($page, 'thread-start'); + waitFor($page, '@thread-start'); expect($page->script("!!document.querySelector('[data-testid=\"composer-{$id}-toolbar\"] [data-testid=\"thread-start\"]')"))->toBeTrue(); $page->click('@thread-start'); - waitForTestId($page, 'thread-reply-0'); + waitFor($page, '@thread-reply-0'); $page->assertVisible("@composer-{$id}-caption-collapsed") ->fill('@thread-reply-0', 'Second post') ->assertSeeIn("@composer-char-count-{$id}", (string) ($limit - mb_strlen('Second post'))) ->click('@thread-add-reply'); - waitForTestId($page, 'thread-reply-1'); + waitFor($page, '@thread-reply-1'); $page->fill('@thread-reply-1', 'Third post') ->assertVisible('@thread-reply-collapsed-0') ->assertSeeIn('@preview-thread-reply-1', 'Third post') ->click('@thread-reply-remove-0'); - waitForTestIdGone($page, 'thread-reply-1'); - waitForTestIdGone($page, 'thread-reply-collapsed-1'); + waitForGone($page, '@thread-reply-1, @thread-reply-collapsed-1'); $page->click("@composer-{$id}-caption-collapsed"); - waitForTestId($page, "composer-caption-{$id}"); + waitFor($page, "@composer-caption-{$id}"); $page->assertNoJavaScriptErrors(); $page->click('@composer-save-draft')->assertMissing('@post-composer-dialog'); @@ -966,11 +959,11 @@ function fakeChannelSettingsApis(): void $post = seedChannelSettingsPost(Platform::Bluesky, ContentType::BlueskyPost); $page = visit(route('app.posts.edit', $post))->resize(1280, 900); - waitForTestId($page, 'thread-start'); + waitFor($page, '@thread-start'); $page->click('@thread-start'); - waitForTestId($page, 'thread-reply-0'); + waitFor($page, '@thread-reply-0'); $page->fill('@thread-reply-0', "\u{200B}\u{00A0}"); - waitForTestIdState($page, 'composer-submit', "element.getAttribute('aria-disabled') === 'true'"); + waitForState($page, '@composer-submit', "element.getAttribute('aria-disabled') === 'true'"); expect($page->script('document.querySelector(\'[data-testid="composer-submit"]\').getAttribute(\'aria-disabled\')'))->toBe('true'); $page->assertNoJavaScriptErrors(); @@ -988,13 +981,13 @@ public function displayText(string $content, Platform $platform): string $post = seedChannelSettingsPost(Platform::Bluesky, ContentType::BlueskyPost); $page = visit(route('app.posts.edit', $post))->resize(1280, 900); - waitForTestId($page, 'thread-start'); + waitFor($page, '@thread-start'); $page->click('@thread-start'); - waitForTestId($page, 'thread-reply-0'); + waitFor($page, '@thread-reply-0'); $page->fill('@thread-reply-0', 'Fine')->click('@thread-add-reply'); - waitForTestId($page, 'thread-reply-1'); + waitFor($page, '@thread-reply-1'); $page->fill('@thread-reply-1', 'Too long for the server')->click('@composer-submit'); - waitForTestId($page, 'thread-reply-error-1'); + waitFor($page, '@thread-reply-error-1'); $page->assertSeeIn('@thread-reply-error-1', __('posts.form.thread.reply_too_long', ['limit' => 300, 'over' => 100])) ->assertMissing('@thread-reply-error-0') @@ -1009,7 +1002,7 @@ public function displayText(string $content, Platform $platform): string $post->update(['meta' => ['thread_replies' => [['text' => 'With a photo', 'media' => [MediaItem::fromMedia($image)->toArray()]], 'Plain reply']]]); $page = visit(route('app.posts.edit', $post))->resize(1280, 900); - waitForTestId($page, "composer-{$id}-reply-0-media-item-0"); + waitFor($page, "@composer-{$id}-reply-0-media-item-0"); $page->assertMissing("@composer-{$id}-media-item-0") ->assertMissing("@composer-{$id}-reply-0-dropzone") @@ -1018,7 +1011,7 @@ public function displayText(string $content, Platform $platform): string ->assertPresent("@composer-{$id}-media-source-menu") ->assertNotPresent("@composer-{$id}-add-media") ->click('@thread-reply-collapsed-0'); - waitForTestId($page, 'thread-reply-0'); + waitFor($page, '@thread-reply-0'); $page->assertPresent("@composer-{$id}-reply-0-media-item-0") ->assertPresent("@composer-{$id}-reply-0-dropzone") ->assertMissing("@composer-{$id}-dropzone"); @@ -1045,7 +1038,7 @@ public function displayText(string $content, Platform $platform): string $post->update(['meta' => ['spoiler_text' => 'Spoilers', 'thread_replies' => ['Hidden reply']]]); $page = visit(route('app.posts.edit', $post))->resize(1280, 900); - waitForTestId($page, 'mastodon-preview-content-warning'); + waitFor($page, '@mastodon-preview-content-warning'); expect($page->script("[...document.querySelectorAll('[data-testid=\"preview-thread\"] [data-testid=\"mastodon-preview-content-warning\"]')].map((warning) => warning.innerText.replace(/\\s+/g, ' ').trim())")) ->toBe(array_fill(0, 2, 'Spoilers '.__('posts.composer.preview.tap_to_reveal'))); @@ -1062,13 +1055,13 @@ public function displayText(string $content, Platform $platform): string $id = $post->social_account_id; $page = visit(route('app.posts.edit', $post))->resize(1280, 900); - waitForTestId($page, 'thread-start'); + waitFor($page, '@thread-start'); $page->click('@thread-start'); - waitForTestId($page, 'thread-reply-0'); + waitFor($page, '@thread-reply-0'); $page->fill('@thread-reply-0', 'Second tweet') ->assertSeeIn("@composer-char-count-{$id}", (string) ($limit - mb_strlen('Second tweet'))); - waitForTestId($page, 'preview-thread-reply-0'); + waitFor($page, '@preview-thread-reply-0'); $page->assertSeeIn('@preview-thread-reply-0', 'Second tweet') ->assertNoJavaScriptErrors(); @@ -1085,7 +1078,7 @@ public function displayText(string $content, Platform $platform): string $post = seedChannelSettingsPost(Platform::X, ContentType::XPost); $page = visit(route('app.posts.edit', $post))->resize(1280, 900); - waitForTestId($page, 'x-ai-generated'); + waitFor($page, '@x-ai-generated'); $page->assertVisible('@x-ai-generated-info') ->click('@x-ai-generated') @@ -1102,25 +1095,25 @@ public function displayText(string $content, Platform $platform): string $id = $post->social_account_id; $page = visit(route('app.posts.edit', $post))->resize(1280, 900); - waitForTestId($page, "composer-caption-{$id}"); + waitFor($page, "@composer-caption-{$id}"); $page->keys("@composer-caption-{$id}", 'Shift+Enter'); - waitForTestId($page, 'thread-reply-0'); + waitFor($page, '@thread-reply-0'); $page->fill('@thread-reply-0', 'Second tweet') ->keys('@thread-reply-0', 'Shift+Enter'); - waitForTestId($page, 'thread-reply-1'); + waitFor($page, '@thread-reply-1'); expect($page->script('document.activeElement?.dataset.testid'))->toBe('thread-reply-1'); $page->keys('@thread-reply-1', 'Backspace'); - waitForTestId($page, 'thread-reply-0'); + waitFor($page, '@thread-reply-0'); expect($page->script("document.querySelectorAll('[data-testid^=\"thread-reply-collapsed-\"], [data-testid^=\"thread-reply-\"]:not([data-testid*=\"remove\"]):not([data-testid*=\"error\"]):not([data-testid*=\"collapsed\"])').length"))->toBe(1) ->and($page->script('document.activeElement?.dataset.testid'))->toBe('thread-reply-0'); $page->fill('@thread-reply-0', '') ->keys('@thread-reply-0', 'Backspace'); - waitForTestId($page, "composer-caption-{$id}"); + waitFor($page, "@composer-caption-{$id}"); expect($page->script('document.activeElement?.dataset.testid'))->toBe("composer-caption-{$id}"); $page->assertMissing('@thread-reply-0') @@ -1136,28 +1129,28 @@ public function displayText(string $content, Platform $platform): string $id = $post->social_account_id; $page = visit(route('app.posts.edit', $post))->resize(1440, 1000); - waitForTestId($page, "composer-caption-{$id}"); + waitFor($page, "@composer-caption-{$id}"); $page->script("(() => { const c = document.querySelector('[data-testid=\"composer-caption-{$id}\"]'); c.focus(); c.setSelectionRange(c.value.length, c.value.length); })()"); $page->keys("@composer-caption-{$id}", 'ArrowDown'); - waitForTestId($page, 'thread-reply-0'); + waitFor($page, '@thread-reply-0'); expect($page->script('[document.activeElement?.dataset.testid, document.activeElement?.selectionStart]'))->toBe(['thread-reply-0', 0]); $page->script("(() => { const r = document.querySelector('[data-testid=\"thread-reply-0\"]'); r.setSelectionRange(r.value.length, r.value.length); })()"); $page->keys('@thread-reply-0', 'ArrowDown'); - waitForTestId($page, 'thread-reply-1'); + waitFor($page, '@thread-reply-1'); expect($page->script('document.activeElement?.dataset.testid'))->toBe('thread-reply-1'); $page->keys('@thread-reply-1', 'ArrowUp'); - waitForTestId($page, 'thread-reply-0'); + waitFor($page, '@thread-reply-0'); expect($page->script('[document.activeElement?.dataset.testid, document.activeElement?.selectionStart === document.activeElement?.value.length]'))->toBe(['thread-reply-0', true]); $page->script("document.querySelector('[data-testid=\"thread-reply-0\"]').setSelectionRange(0, 0)"); $page->keys('@thread-reply-0', 'ArrowUp'); - waitForTestId($page, "composer-caption-{$id}"); + waitFor($page, "@composer-caption-{$id}"); expect($page->script('document.activeElement?.dataset.testid'))->toBe("composer-caption-{$id}"); $page->assertNoJavaScriptErrors(); @@ -1171,15 +1164,10 @@ public function displayText(string $content, Platform $platform): string $id = $post->social_account_id; $page = visit(route('app.posts.edit', $post))->resize(1280, 700); - waitForTestId($page, 'thread-reply-collapsed-2'); + waitFor($page, '@thread-reply-collapsed-2'); $page->click('@thread-reply-collapsed-2'); - waitForCondition($page, <<<'JS' - (() => { - const reply = document.querySelector('[data-testid="preview-thread-reply-2"]').getBoundingClientRect(); - return reply.top >= 0 && reply.bottom <= window.innerHeight; - })() - JS); + waitForState($page, '@preview-thread-reply-2', 'element.getBoundingClientRect().top >= 0 && element.getBoundingClientRect().bottom <= window.innerHeight'); $page->assertNoJavaScriptErrors(); }); @@ -1190,11 +1178,11 @@ public function displayText(string $content, Platform $platform): string $post->update(['content' => 'Scheduled post', 'status' => Status::Scheduled, 'scheduled_at' => now()->addDays(2)]); $page = visit(route('app.posts.edit', $post))->resize(1440, 1000); - waitForTestId($page, 'composer-save-draft'); + waitFor($page, '@composer-save-draft'); $page->assertSeeIn('@composer-save-draft', __('posts.publish.actions.move_to_drafts')) ->click('@composer-save-draft'); - waitForTestIdGone($page, 'composer-save-draft'); + waitForGone($page, '@composer-save-draft'); expect($post->fresh()->status)->toBe(Status::Draft); $page->assertNoJavaScriptErrors(); @@ -1205,7 +1193,7 @@ public function displayText(string $content, Platform $platform): string $post = seedChannelSettingsPost(Platform::GoogleBusiness, ContentType::GoogleBusinessPost); $page = visit(route('app.posts.edit', $post))->resize(1440, 1000); - waitForTestId($page, 'google-business-preview'); + waitFor($page, '@google-business-preview'); expect($page->script("document.querySelector('[data-testid=\"google-business-preview\"] .text-info.rounded-full, [data-testid=\"google-business-preview\"] [data-testid=\"preview-verified\"]') === null"))->toBeTrue(); $page->assertNoJavaScriptErrors(); @@ -1219,11 +1207,11 @@ public function displayText(string $content, Platform $platform): string $id = $post->social_account_id; $page = visit(route('app.posts.edit', $post))->resize(1440, 1000); - waitForTestId($page, "composer-meta-warning-{$id}"); + waitFor($page, "@composer-meta-warning-{$id}"); $page->assertSeeIn("@composer-meta-warning-{$id}", __('posts.form.youtube.title_required')) ->fill('@youtube-title', 'My short'); - waitForTestIdGone($page, "composer-meta-warning-{$id}"); + waitForGone($page, "@composer-meta-warning-{$id}"); $page->assertMissing("@composer-meta-warning-{$id}") ->assertNoJavaScriptErrors(); @@ -1234,7 +1222,7 @@ public function displayText(string $content, Platform $platform): string $post = seedChannelSettingsPost(Platform::TikTok, ContentType::TikTokVideo); $page = visit(route('app.posts.edit', $post))->resize(1280, 900); - waitForTestId($page, 'tiktok-privacy-level'); + waitFor($page, '@tiktok-privacy-level'); $page->assertMissing('@tiktok-posting-to') ->assertVisible('@tiktok-disclose') @@ -1261,7 +1249,7 @@ public function displayText(string $content, Platform $platform): string $id = $post->social_account_id; $page = visit(route('app.posts.edit', $post))->resize(1440, 1000); - waitForTestId($page, 'tiktok-privacy-level'); + waitFor($page, '@tiktok-privacy-level'); $page->assertMissing("@composer-type-{$id}-tiktok_video") ->assertMissing("@composer-type-{$id}-tiktok_photo") @@ -1270,9 +1258,9 @@ public function displayText(string $content, Platform $platform): string $page->assertVisible('@tiktok-auto-add-music') ->assertAttribute('@tiktok-auto-add-music', 'data-state', 'unchecked') ->click('@tiktok-auto-add-music'); - waitForTestIdState($page, 'tiktok-auto-add-music', "element.dataset.state === 'checked'"); + waitForState($page, '@tiktok-auto-add-music', "element.dataset.state === 'checked'"); $page->click('@composer-save-draft'); - waitForTestIdGone($page, 'post-composer-dialog'); + waitForGone($page, '@post-composer-dialog'); expect($post->fresh()->meta['auto_add_music'] ?? null)->toBeTrue(); $page->assertNoJavaScriptErrors(); @@ -1287,7 +1275,7 @@ public function displayText(string $content, Platform $platform): string $id = $post->social_account_id; $page = visit(route('app.posts.edit', $post))->resize(1440, 1000); - waitForTestId($page, "composer-media-warning-{$id}"); + waitFor($page, "@composer-media-warning-{$id}"); $page->assertSeeIn("@composer-media-warning-{$id}", __("posts.form.warnings.{$warning}", $params)) ->assertNoJavaScriptErrors(); @@ -1303,7 +1291,7 @@ public function displayText(string $content, Platform $platform): string $id = $post->social_account_id; $page = visit(route('app.posts.edit', $post))->resize(1440, 1000); - waitForTestId($page, "composer-{$id}-media-item-1"); + waitFor($page, "@composer-{$id}-media-item-1"); foreach ([ "composer-{$id}-drag-handle-0" => 'reorder', @@ -1313,9 +1301,9 @@ public function displayText(string $content, Platform $platform): string "composer-{$id}-edit-0" => 'edit', ] as $testId => $key) { $page->hover('@composer-save-draft'); - waitForCondition($page, "!document.querySelector('[data-slot=\"tooltip-content\"]')"); + waitForGone($page, '[data-slot="tooltip-content"]'); $page->hover("@composer-{$id}-media-item-0")->hover("@{$testId}"); - waitForCondition($page, "[...document.querySelectorAll('[data-slot=\"tooltip-content\"]')].some((tip) => tip.textContent.includes(".json_encode(__("posts.composer.media_actions.{$key}")).'))'); + waitForText($page, '[data-slot="tooltip-content"]', __("posts.composer.media_actions.{$key}")); expect($page->script("[...document.querySelectorAll('[data-slot=\"tooltip-content\"]')].map((tip) => tip.textContent.trim()).join('|')")) ->toContain(__("posts.composer.media_actions.{$key}")); @@ -1330,7 +1318,7 @@ public function displayText(string $content, Platform $platform): string $post = seedChannelSettingsPost(Platform::TikTok, ContentType::TikTokPhoto, $photo); $page = visit(route('app.posts.edit', $post))->resize(1440, 1000); - waitForTestId($page, 'tiktok-preview'); + waitFor($page, '@tiktok-preview'); expect($page->script("document.querySelectorAll('[data-testid=\"tiktok-preview\"] img[aria-hidden=\"true\"]').length"))->toBe(0) ->and($page->script("document.querySelectorAll('[data-testid=\"tiktok-preview\"] img').length"))->toBeGreaterThan(0); @@ -1344,12 +1332,12 @@ public function displayText(string $content, Platform $platform): string $post->update(['meta' => ['topic_type' => 'EVENT', 'event' => ['title' => 'Launch night', 'start_date' => channelSettingsEventDay(0), 'end_date' => channelSettingsEventDay(2), 'start_time' => '09:00']]]); $page = visit(route('app.posts.edit', $post))->resize(1280, 900); - waitForTestId($page, 'google-business-start-time'); + waitFor($page, '@google-business-start-time'); expect($page->script("document.querySelector('[data-testid=\"google-business-start-time\"]').type"))->toBe('text'); $page->click('@google-business-start-time'); - waitForTestId($page, 'time-field-option-0900'); + waitFor($page, '@time-field-option-0900'); expect($page->script("document.querySelector('[data-testid=\"time-field-option-1830\"]').textContent.trim()"))->toBe($shown); $page->assertAttribute('@time-field-option-0900', 'aria-selected', 'true') ->click('@time-field-option-1830'); @@ -1378,7 +1366,7 @@ public function displayText(string $content, Platform $platform): string $post->update(['media' => $images->map(fn (Media $media): array => MediaItem::fromMedia($media)->toArray())->all()]); $page = visit(route('app.posts.edit', $post))->resize(1440, 1000); - waitForTestId($page, 'preview-media-collage'); + waitFor($page, '@preview-media-collage'); $layout = $page->script(<<<'JS' (() => { @@ -1404,7 +1392,7 @@ public function displayText(string $content, Platform $platform): string $testId = str_replace('{id}', $post->social_account_id, $counter); $page = visit(route('app.posts.edit', $post))->resize(1440, 1000); - waitForTestId($page, $testId); + waitFor($page, "@{$testId}"); $look = $page->script(<< { @@ -1447,14 +1435,14 @@ public function displayText(string $content, Platform $platform): string $post = seedChannelSettingsPost(Platform::InstagramFacebook, ContentType::InstagramReel); $page = visit(route('app.posts.edit', $post))->resize(1280, 900); - waitForTestId($page, 'instagram-music-add'); + waitFor($page, '@instagram-music-add'); $page->click('@instagram-music-add'); - waitForTestId($page, 'instagram-music-failed'); + waitFor($page, '@instagram-music-failed'); $page->assertSeeIn('@instagram-music-failed', __('posts.form.instagram.music.search_failed')) ->keys('@instagram-music-search', 'Escape'); - waitForTestIdGone($page, 'instagram-music-picker'); + waitForGone($page, '@instagram-music-picker'); $page->assertNoJavaScriptErrors(); }); @@ -1468,12 +1456,12 @@ public function displayText(string $content, Platform $platform): string $post = seedChannelSettingsPost(Platform::InstagramFacebook, ContentType::InstagramReel); $page = visit(route('app.posts.edit', $post))->resize(1280, 900); - waitForTestId($page, 'instagram-music-add'); + waitFor($page, '@instagram-music-add'); $page->click('@instagram-music-add'); - waitForTestId($page, 'instagram-music-track-audio-trending'); + waitFor($page, '@instagram-music-track-audio-trending'); $page->type('@instagram-music-search', 'grudge'); - waitForTestId($page, 'instagram-music-track-audio-grudge'); + waitFor($page, '@instagram-music-track-audio-grudge'); $page->assertMissing('@instagram-music-track-audio-trending')->assertNoJavaScriptErrors(); $musicSearches = collect(Http::recorded()) @@ -1495,22 +1483,22 @@ public function displayText(string $content, Platform $platform): string ]]]); $page = visit(route('app.posts.edit', $post))->resize(1280, 900); - waitForTestId($page, 'instagram-music-selected-preview'); + waitFor($page, '@instagram-music-selected-preview'); $page->script('HTMLMediaElement.prototype.play = function () { return Promise.resolve(); }; HTMLMediaElement.prototype.pause = function () {};'); $page->click('@instagram-music-selected-preview'); - waitForTestIdState($page, 'instagram-music-selected-preview', "element.getAttribute('aria-label') === '".__('posts.form.instagram.music.pause')."'"); + waitForState($page, '@instagram-music-selected-preview', "element.getAttribute('aria-label') === '".__('posts.form.instagram.music.pause')."'"); $page->assertAttribute('@instagram-music-selected-preview', 'aria-label', __('posts.form.instagram.music.pause')); $page->click('@instagram-music-selected-preview'); - waitForTestIdState($page, 'instagram-music-selected-preview', "element.getAttribute('aria-label') === '".__('posts.form.instagram.music.play')."'"); + waitForState($page, '@instagram-music-selected-preview', "element.getAttribute('aria-label') === '".__('posts.form.instagram.music.play')."'"); $page->assertAttribute('@instagram-music-selected-preview', 'aria-label', __('posts.form.instagram.music.play')); $page->click('@instagram-music-remove'); - waitForTestId($page, 'instagram-music-add'); + waitFor($page, '@instagram-music-add'); $page->assertMissing('@instagram-music-selected') ->click('@composer-save-draft'); - waitForTestIdGone($page, 'post-composer-dialog'); + waitForGone($page, '@post-composer-dialog'); $page->assertNoJavaScriptErrors(); expect(data_get($post->fresh()->meta, 'audio'))->toBeNull(); @@ -1522,7 +1510,7 @@ public function displayText(string $content, Platform $platform): string $post->update(['meta' => ['trial_graduation' => 'MANUAL']]); $page = visit(route('app.posts.edit', $post))->resize(1280, 900); - waitForTestId($page, 'instagram-trial-reel-hint'); + waitFor($page, '@instagram-trial-reel-hint'); $page->assertSeeIn('@instagram-trial-reel-hint', __('posts.form.instagram.trial_reel.manual_hint')) ->assertSeeIn('@instagram-preview-trial', __('posts.form.instagram.trial_reel.badge')) @@ -1534,17 +1522,17 @@ public function displayText(string $content, Platform $platform): string $post = seedChannelSettingsPost(Platform::Instagram, ContentType::InstagramReel); $page = visit(route('app.posts.edit', $post))->resize(1280, 900); - waitForTestId($page, 'instagram-collaborator-input'); + waitFor($page, '@instagram-collaborator-input'); $page->type('@instagram-collaborator-input', '@alice') ->keys('@instagram-collaborator-input', 'Enter'); - waitForTestId($page, 'instagram-collaborators-notice'); + waitFor($page, '@instagram-collaborators-notice'); $page->click('@instagram-collaborators-notice-dismiss') ->type('@instagram-collaborator-input', 'bob') ->click('@instagram-collaborator-add'); - waitForTestId($page, 'instagram-collaborator-bob'); + waitFor($page, '@instagram-collaborator-bob'); $page->click('@composer-save-draft'); - waitForTestIdGone($page, 'post-composer-dialog'); + waitForGone($page, '@post-composer-dialog'); $page->assertNoJavaScriptErrors(); expect(data_get($post->fresh()->meta, 'collaborators'))->toBe(['alice', 'bob']); @@ -1555,10 +1543,10 @@ public function displayText(string $content, Platform $platform): string $post = seedChannelSettingsPost(Platform::Instagram, ContentType::InstagramReel); $page = visit(route('app.posts.edit', $post))->resize(1280, 900); - waitForTestId($page, 'instagram-collaborator-input'); + waitFor($page, '@instagram-collaborator-input'); $page->type('@instagram-collaborator-input', '@alice') ->click('@composer-save-draft'); - waitForTestIdGone($page, 'post-composer-dialog'); + waitForGone($page, '@post-composer-dialog'); $page->assertNoJavaScriptErrors(); expect(data_get($post->fresh()->meta, 'collaborators'))->toBe(['alice']); @@ -1578,8 +1566,8 @@ public function displayText(string $content, Platform $platform): string ]]]); $page = visit(route('app.posts.edit', $post))->resize(1280, 900); - waitForTestId($page, 'instagram-music-selected-preview'); - waitForTestIdState($page, 'instagram-reel-preview', "element.querySelector('video')"); + waitFor($page, '@instagram-music-selected-preview'); + waitFor($page, '@instagram-reel-preview video'); $page->script(<<<'JS' (() => { const where = (media, suffix = '') => `${media.closest?.('[data-testid="instagram-reel-preview"]') ? 'PREVIEW' : media.tagName}${suffix}`; @@ -1628,8 +1616,8 @@ public function displayText(string $content, Platform $platform): string ]]]); $page = visit(route('app.posts.edit', $post))->resize(1280, 900); - waitForTestId($page, 'instagram-music-selected-preview'); - waitForTestIdState($page, 'instagram-reel-preview', "element.querySelector('video')"); + waitFor($page, '@instagram-music-selected-preview'); + waitFor($page, '@instagram-reel-preview video'); $page->script(<<<'JS' (() => { const where = (media, suffix = '') => `${media.closest?.('[data-testid="instagram-reel-preview"]') ? 'PREVIEW' : media.tagName}${suffix}`; @@ -1655,7 +1643,7 @@ public function displayText(string $content, Platform $platform): string $post->update(['meta' => ['collaborators' => ['alice']]]); $page = visit(route('app.posts.edit', $post))->resize(1280, 900); - waitForTestId($page, 'instagram-collaborator-alice'); + waitFor($page, '@instagram-collaborator-alice'); expect($page->script("getComputedStyle(document.querySelector('[data-testid=\"instagram-collaborator-alice\"]')).cursor"))->toBe('alias') ->and($page->script("document.querySelector('[data-testid=\"instagram-collaborator-alice\"]').target"))->toBe('_blank'); diff --git a/tests/Browser/ComposerUnsavedChangesTest.php b/tests/Browser/ComposerUnsavedChangesTest.php index a7446cef21..dd95f11bbf 100644 --- a/tests/Browser/ComposerUnsavedChangesTest.php +++ b/tests/Browser/ComposerUnsavedChangesTest.php @@ -35,9 +35,9 @@ function seedUnsavedChangesReel(): Post $post = seedUnsavedChangesReel(); $page = visit(route('app.posts.edit', $post))->resize(1280, 900); - waitForTestId($page, 'instagram-share-to-feed'); + waitFor($page, '@instagram-share-to-feed'); $page->click('@composer-close'); - waitForTestIdGone($page, 'post-composer-dialog'); + waitForGone($page, '@post-composer-dialog'); $page->assertMissing('@post-composer-dialog') ->assertMissing('@unsaved-changes-dialog') @@ -48,18 +48,18 @@ function seedUnsavedChangesReel(): Post $post = seedUnsavedChangesReel(); $page = visit(route('app.posts.edit', $post))->resize(1280, 900); - waitForTestId($page, 'instagram-share-to-feed'); + waitFor($page, '@instagram-share-to-feed'); $page->click('@instagram-share-to-feed') ->click('@composer-close'); - waitForTestId($page, 'unsaved-changes-dialog'); + waitFor($page, '@unsaved-changes-dialog'); $page->click('@unsaved-changes-keep'); - waitForTestIdGone($page, 'unsaved-changes-dialog'); + waitForGone($page, '@unsaved-changes-dialog'); $page->assertVisible('@post-composer-dialog') ->click('@composer-close'); - waitForTestId($page, 'unsaved-changes-discard'); + waitFor($page, '@unsaved-changes-discard'); $page->click('@unsaved-changes-discard'); - waitForTestIdGone($page, 'post-composer-dialog'); + waitForGone($page, '@post-composer-dialog'); $page->assertMissing('@post-composer-dialog')->assertNoJavaScriptErrors(); expect($post->fresh()->meta)->toBe([]); @@ -69,10 +69,10 @@ function seedUnsavedChangesReel(): Post $post = seedUnsavedChangesReel(); $page = visit(route('app.posts.edit', $post))->resize(1280, 900); - waitForTestId($page, 'instagram-share-to-feed'); + waitFor($page, '@instagram-share-to-feed'); $page->click('@instagram-share-to-feed') ->click('@composer-save-draft'); - waitForTestIdGone($page, 'post-composer-dialog'); + waitForGone($page, '@post-composer-dialog'); $page->assertMissing('@unsaved-changes-dialog')->assertNoJavaScriptErrors(); expect(data_get($post->fresh()->meta, 'share_to_feed'))->toBeFalse(); @@ -84,9 +84,9 @@ function seedUnsavedChangesReel(): Post $page->fill("@composer-caption-{$post->social_account_id}", 'Saved from the composer') ->click('@composer-save-draft'); - waitForTestIdGone($page, 'post-composer-dialog'); + waitForGone($page, '@post-composer-dialog'); waitForCondition($page, "!location.search.includes('edit=')"); - waitForTestIdState($page, "post-card-{$post->id}", "element.textContent.includes('Saved from the composer')"); + waitForText($page, "@post-card-{$post->id}", 'Saved from the composer'); $page->assertPathIs(unsavedChangesListPath()) ->assertSeeIn("@post-card-{$post->id}", 'Saved from the composer'); @@ -105,9 +105,9 @@ function seedUnsavedChangesReel(): Post $page->fill("@composer-caption-{$post->social_account_id}", 'Saved after a partial reload') ->click('@composer-save-draft'); - waitForTestIdGone($page, 'post-composer-dialog'); + waitForGone($page, '@post-composer-dialog'); waitForCondition($page, "!location.search.includes('edit=')"); - waitForTestIdState($page, "post-card-{$post->id}", "element.textContent.includes('Saved after a partial reload')"); + waitForText($page, "@post-card-{$post->id}", 'Saved after a partial reload'); $page->back(); waitForPath($page, parse_url(route('app.settings.preferences'), PHP_URL_PATH)); @@ -120,7 +120,7 @@ function seedUnsavedChangesReel(): Post $page = openUnsavedChangesFromList($post, route('app.settings.preferences')); $page->click('@composer-close'); - waitForTestIdGone($page, 'post-composer-dialog'); + waitForGone($page, '@post-composer-dialog'); waitForCondition($page, "!location.search.includes('edit=')"); $page->assertPathIs(unsavedChangesListPath())->assertMissing('@unsaved-changes-dialog'); @@ -135,7 +135,7 @@ function seedUnsavedChangesReel(): Post $post = seedUnsavedChangesReel(); $page = visit(route('app.posts.edit', $post))->resize(1280, 900); - waitForTestId($page, 'instagram-share-to-feed'); + waitFor($page, '@instagram-share-to-feed'); $page->script(<<<'JS' (() => { window.__backs = 0; @@ -146,7 +146,7 @@ function seedUnsavedChangesReel(): Post JS); $page->click($leave === 'save' ? '@composer-save-draft' : '@composer-close'); - waitForTestIdGone($page, 'post-composer-dialog'); + waitForGone($page, '@post-composer-dialog'); waitForCondition($page, "!location.search.includes('edit=')"); $page->script('new Promise((resolve) => setTimeout(resolve, 500))'); @@ -176,16 +176,16 @@ function seedUnsavedChangesReel(): Post $page = visit(route('app.settings.preferences'))->resize(1280, 900)->navigate($list); if ($from === 'calendar') { - waitForTestId($page, "calendar-post-{$post->id}"); + waitFor($page, "@calendar-post-{$post->id}"); $page->click("@calendar-post-{$post->id}"); } - waitForTestId($page, "post-edit-{$post->id}"); + waitFor($page, "@post-edit-{$post->id}"); $page->click("@post-edit-{$post->id}"); - waitForTestId($page, 'instagram-share-to-feed'); + waitFor($page, '@instagram-share-to-feed'); $page->click('@instagram-share-to-feed') ->click('@composer-save-draft'); - waitForTestIdGone($page, 'post-composer-dialog'); + waitForGone($page, '@post-composer-dialog'); $page->script('new Promise((resolve) => setTimeout(resolve, 500))'); waitForPath($page, $listPath); waitForCondition($page, "!location.search.includes('edit=')"); @@ -222,7 +222,7 @@ function seedUnsavedChangesReel(): Post $page->click('@instagram-share-to-feed') ->click('@composer-save-draft'); - waitForTestIdGone($page, 'post-composer-dialog'); + waitForGone($page, '@post-composer-dialog'); $page->script('new Promise((resolve) => setTimeout(resolve, 500))'); expect($page->script('window.__saved'))->toBeTrue() @@ -258,7 +258,7 @@ function seedUnsavedChangesReel(): Post $page = visit(route('app.posts.edit', $post))->resize(1280, 900); if ($clickTextFirst) { - waitForTestId($page, 'composer-editor-column'); + waitFor($page, '@composer-editor-column'); $page->script(<<<'JS' (() => { const target = document.querySelector('[data-testid="composer-editor-column"] [contenteditable="true"]') ?? document.querySelector('[data-testid="composer-editor-column"]'); @@ -269,10 +269,10 @@ function seedUnsavedChangesReel(): Post })() JS); } - waitForTestId($page, 'channel-settings-rows'); + waitFor($page, '@channel-settings-rows'); $page->script('new Promise((resolve) => setTimeout(resolve, 1000))'); $page->click('@composer-close'); - waitForTestIdGoneOrShown($page, gone: 'post-composer-dialog', shown: 'unsaved-changes-dialog'); + waitForGoneOrShown($page, gone: '@post-composer-dialog', shown: '@unsaved-changes-dialog'); $page->assertMissing('@unsaved-changes-dialog') ->assertMissing('@post-composer-dialog') @@ -293,10 +293,10 @@ function seedUnsavedChangesReel(): Post $post->forceFill(['content_type' => $contentType, 'media' => $media, ...$schedule])->save(); $page = visit(route('app.posts.edit', $post))->resize(1280, 900); - waitForTestId($page, 'channel-settings-rows'); + waitFor($page, '@channel-settings-rows'); $page->script('new Promise((resolve) => setTimeout(resolve, 1000))'); $page->click('@composer-close'); - waitForTestIdGone($page, 'post-composer-dialog'); + waitForGone($page, '@post-composer-dialog'); $page->assertMissing('@unsaved-changes-dialog')->assertNoJavaScriptErrors(); })->with([ @@ -315,16 +315,16 @@ function seedUnsavedChangesReel(): Post $post->forceFill(['status' => PostStatus::Scheduled, 'scheduled_at' => now()->addDay()])->save(); $page = visit(route('app.posts.edit', $post))->resize(1280, 900); - waitForTestId($page, 'composer-schedule-trigger'); + waitFor($page, '@composer-schedule-trigger'); $page->click('@composer-schedule-trigger'); - waitForTestId($page, 'composer-schedule-more-actions'); + waitFor($page, '@composer-schedule-more-actions'); $page->click('@composer-schedule-more-actions'); - waitForTestId($page, 'composer-schedule-now'); + waitFor($page, '@composer-schedule-now'); $page->click('@composer-schedule-now'); - waitForTestIdState($page, 'composer-submit', "element.dataset.scheduleMode === 'now'"); + waitForState($page, '@composer-submit', "element.dataset.scheduleMode === 'now'"); $page->click('@composer-close'); - waitForTestId($page, 'unsaved-changes-dialog'); + waitFor($page, '@unsaved-changes-dialog'); $page->assertVisible('@unsaved-changes-dialog')->assertNoJavaScriptErrors(); }); @@ -348,15 +348,15 @@ function seedUnsavedChangesReel(): Post $this->actingAs($user); $page = visit(route('app.posts.edit', $post))->resize(1280, 900); - waitForCondition($page, "document.querySelector('[data-testid=\"channel-settings-rows\"] [role=\"combobox\"]:not([disabled])')"); + waitFor($page, '@channel-settings-rows [role="combobox"]:not([disabled])'); $page->assertNoJavaScriptErrors() ->script("document.querySelector('[data-testid=\"channel-settings-rows\"] [role=\"combobox\"]').click()"); - waitForCondition($page, "[...document.querySelectorAll('[role=\"option\"]')].some((option) => option.textContent.includes('general'))"); + waitForText($page, '[role="option"]', 'general'); $page->script("[...document.querySelectorAll('[role=\"option\"]')].find((option) => option.textContent.includes('general')).click()"); - waitForCondition($page, "document.querySelector('[data-testid=\"channel-settings-rows\"] [role=\"combobox\"]')?.textContent.includes('general')"); + waitForText($page, '@channel-settings-rows [role="combobox"]', 'general'); $page->click('@composer-save-draft'); - waitForTestIdGone($page, 'post-composer-dialog'); + waitForGone($page, '@post-composer-dialog'); expect($post->fresh()->meta)->toMatchArray(['channel_id' => '111', 'channel_name' => 'general']); }); @@ -365,19 +365,19 @@ function seedUnsavedChangesReel(): Post $post = seedUnsavedChangesReel(); $page = visit(route('app.posts.edit', $post))->resize(1280, 900); - waitForTestId($page, 'instagram-share-to-feed'); + waitFor($page, '@instagram-share-to-feed'); $page->click('@instagram-share-to-feed') ->script("document.querySelector('[data-testid=\"sidebar-logo\"]').click()"); - waitForTestId($page, 'unsaved-changes-dialog'); + waitFor($page, '@unsaved-changes-dialog'); $page->assertVisible('@post-composer-dialog') ->click('@unsaved-changes-keep'); - waitForTestIdGone($page, 'unsaved-changes-dialog'); + waitForGone($page, '@unsaved-changes-dialog'); $page->assertVisible('@post-composer-dialog') ->script("document.querySelector('[data-testid=\"sidebar-logo\"]').click()"); - waitForTestId($page, 'unsaved-changes-discard'); + waitFor($page, '@unsaved-changes-discard'); $page->click('@unsaved-changes-discard'); - waitForTestIdGone($page, 'post-composer-dialog'); + waitForGone($page, '@post-composer-dialog'); $page->assertMissing('@post-composer-dialog')->assertNoJavaScriptErrors(); expect($post->fresh()->meta)->toBe([]); @@ -394,9 +394,9 @@ function openUnsavedChangesFromList(Post $post, ?string $from = null, string $re $page = $from === null ? visit($list)->resize(1280, 900) : visit($from)->resize(1280, 900)->navigate($list); - waitForTestId($page, "post-edit-{$post->id}"); + waitFor($page, "@post-edit-{$post->id}"); $page->click("@post-edit-{$post->id}"); - waitForTestId($page, $readyTestId); + waitFor($page, "@{$readyTestId}"); return $page; } @@ -406,20 +406,20 @@ function openUnsavedChangesFromList(Post $post, ?string $from = null, string $re $page = openUnsavedChangesFromList($post); $page->click('@instagram-share-to-feed'); - waitForTestIdState($page, 'instagram-share-to-feed', "element.getAttribute('aria-checked') === 'false'"); + waitForState($page, '@instagram-share-to-feed', "element.getAttribute('aria-checked') === 'false'"); $page->back(); - waitForTestId($page, 'unsaved-changes-dialog'); + waitFor($page, '@unsaved-changes-dialog'); $page->assertVisible('@post-composer-dialog') ->click('@unsaved-changes-keep'); - waitForTestIdGone($page, 'unsaved-changes-dialog'); + waitForGone($page, '@unsaved-changes-dialog'); $page->assertVisible('@post-composer-dialog') ->assertAttribute('@instagram-share-to-feed', 'aria-checked', 'false'); $page->back(); - waitForTestId($page, 'unsaved-changes-discard'); + waitFor($page, '@unsaved-changes-discard'); $page->click('@unsaved-changes-discard'); - waitForTestIdGone($page, 'post-composer-dialog'); + waitForGone($page, '@post-composer-dialog'); $page->assertMissing('@post-composer-dialog') ->assertPathIs(unsavedChangesListPath()) @@ -432,7 +432,7 @@ function openUnsavedChangesFromList(Post $post, ?string $from = null, string $re $page = openUnsavedChangesFromList($post); $page->back(); - waitForTestIdGone($page, 'post-composer-dialog'); + waitForGone($page, '@post-composer-dialog'); $page->assertMissing('@post-composer-dialog') ->assertMissing('@unsaved-changes-dialog') @@ -447,7 +447,7 @@ function openUnsavedChangesFromList(Post $post, ?string $from = null, string $re $page->click('@instagram-share-to-feed') ->click('@composer-save-draft'); - waitForTestIdGone($page, 'post-composer-dialog'); + waitForGone($page, '@post-composer-dialog'); waitForCondition($page, "!location.search.includes('edit=')"); $page->back(); $page->script('new Promise((resolve) => setTimeout(resolve, 800))'); @@ -465,9 +465,9 @@ function openUnsavedChangesFromList(Post $post, ?string $from = null, string $re $page->click('@instagram-share-to-feed') ->click('@composer-close'); - waitForTestId($page, 'unsaved-changes-discard'); + waitFor($page, '@unsaved-changes-discard'); $page->click('@unsaved-changes-discard'); - waitForTestIdGone($page, 'post-composer-dialog'); + waitForGone($page, '@post-composer-dialog'); $page->back(); $page->script('new Promise((resolve) => setTimeout(resolve, 800))'); @@ -477,7 +477,7 @@ function openUnsavedChangesFromList(Post $post, ?string $from = null, string $re expect($post->fresh()->meta)->toBe([]); }); -test('closing an untouched tiktok post asks nothing when the creator settings turn interactions off', function (Platform $platform, ContentType $contentType, array $meta, string $settledCondition) { +test('closing an untouched tiktok post asks nothing when the creator settings turn interactions off', function (Platform $platform, ContentType $contentType, array $meta, string $settledSelector, string $settledState) { Http::fake([ config('trypost.platforms.tiktok.api').'/post/publish/creator_info/query/' => Http::response(['data' => [ 'creator_nickname' => 'Tik', @@ -508,16 +508,16 @@ function openUnsavedChangesFromList(Post $post, ?string $from = null, string $re $this->actingAs($user); $page = visit(route('app.posts.edit', $post))->resize(1280, 900); - waitForTestId($page, 'channel-settings-rows'); - waitForCondition($page, $settledCondition); + waitFor($page, '@channel-settings-rows'); + waitForState($page, $settledSelector, $settledState); $page->click('@composer-close'); - waitForTestIdGone($page, 'post-composer-dialog'); + waitForGone($page, '@post-composer-dialog'); $page->assertMissing('@unsaved-changes-dialog') ->assertMissing('@post-composer-dialog') ->assertNoJavaScriptErrors(); })->with([ - 'tiktok creator turns interactions off' => [Platform::TikTok, ContentType::TikTokVideo, ['allow_comments' => true, 'allow_duet' => true, 'allow_stitch' => true], "document.querySelector('[data-testid=\"tiktok-allow-comments\"]')?.disabled === true"], + 'tiktok creator turns interactions off' => [Platform::TikTok, ContentType::TikTokVideo, ['allow_comments' => true, 'allow_duet' => true, 'allow_stitch' => true], '@tiktok-allow-comments', 'element.disabled'], ]); test('a music cover renewed after the post opened does not count as an unsaved change', function () { @@ -541,13 +541,13 @@ function openUnsavedChangesFromList(Post $post, ?string $from = null, string $re $this->actingAs($user); $page = visit(route('app.posts.edit', $post))->resize(1280, 900); - waitForTestId($page, 'instagram-music-selected-cover'); + waitFor($page, '@instagram-music-selected-cover'); $page->script('new Promise((resolve) => setTimeout(resolve, 500))'); $page->script("document.querySelector('[data-testid=\"instagram-music-selected-cover\"]').dispatchEvent(new Event('error'))"); - waitForTestIdState($page, 'instagram-music-selected-cover', "element.getAttribute('src')?.endsWith('#fresh')"); + waitForState($page, '@instagram-music-selected-cover', "element.getAttribute('src')?.endsWith('#fresh')"); $page->assertAttributeContains('@instagram-music-selected-cover', 'src', '#fresh') ->click('@composer-close'); - waitForTestIdGone($page, 'post-composer-dialog'); + waitForGone($page, '@post-composer-dialog'); $page->assertMissing('@unsaved-changes-dialog') ->assertMissing('@post-composer-dialog') @@ -586,7 +586,7 @@ function seedUnsavedChangesReelWithBareTrack(): Post $post = seedUnsavedChangesReelWithBareTrack(); $page = visit(route('app.posts.edit', $post))->resize(1280, 900); - waitForTestIdState($page, 'instagram-music-selected-cover', "element.getAttribute('src')?.endsWith('#fresh')"); + waitForState($page, '@instagram-music-selected-cover', "element.getAttribute('src')?.endsWith('#fresh')"); $page->assertAttributeContains('@instagram-music-selected-cover', 'src', '#fresh') ->assertSeeIn('@instagram-music-selected', 'Summer Song') @@ -599,7 +599,7 @@ function seedUnsavedChangesReelWithBareTrack(): Post $post = seedUnsavedChangesReelWithBareTrack(); $page = visit(route('app.posts.index', ['tab' => 'drafts']))->resize(1280, 900); - waitForTestId($page, "post-edit-{$post->id}"); + waitFor($page, "@post-edit-{$post->id}"); $page->script(<<<'JS' (() => { const open = XMLHttpRequest.prototype.open; @@ -618,7 +618,7 @@ function seedUnsavedChangesReelWithBareTrack(): Post })() JS); $page->click("@post-edit-{$post->id}"); - waitForTestId($page, 'composer-editor-column'); + waitFor($page, '@composer-editor-column'); $page->script(<<<'JS' (() => { const target = document.querySelector('[data-testid="composer-editor-column"] [contenteditable="true"]') ?? document.querySelector('[data-testid="composer-editor-column"]'); @@ -628,10 +628,10 @@ function seedUnsavedChangesReelWithBareTrack(): Post })() JS); $page->assertMissing('@instagram-music-selected-cover'); - waitForTestIdState($page, 'instagram-music-selected-cover', "element.getAttribute('src')?.endsWith('#fresh')"); + waitForState($page, '@instagram-music-selected-cover', "element.getAttribute('src')?.endsWith('#fresh')"); $page->assertAttributeContains('@instagram-music-selected-cover', 'src', '#fresh') ->click('@composer-close'); - waitForTestIdGoneOrShown($page, gone: 'post-composer-dialog', shown: 'unsaved-changes-dialog'); + waitForGoneOrShown($page, gone: '@post-composer-dialog', shown: '@unsaved-changes-dialog'); $page->assertMissing('@unsaved-changes-dialog') ->assertMissing('@post-composer-dialog') @@ -644,16 +644,16 @@ function seedUnsavedChangesReelWithBareTrack(): Post $page->click('@instagram-share-to-feed') ->script("document.querySelector('[data-testid=\"sidebar-logo\"]').click()"); - waitForTestId($page, 'unsaved-changes-discard'); + waitFor($page, '@unsaved-changes-discard'); $page->click('@unsaved-changes-discard'); - waitForTestIdGone($page, 'post-composer-dialog'); + waitForGone($page, '@post-composer-dialog'); $page->back(); - waitForTestId($page, 'instagram-share-to-feed'); + waitFor($page, '@instagram-share-to-feed'); $page->assertAttribute('@instagram-share-to-feed', 'aria-checked', 'true'); $page->back(); - waitForTestIdGone($page, 'post-composer-dialog'); + waitForGone($page, '@post-composer-dialog'); $page->assertMissing('@post-composer-dialog') ->assertMissing('@unsaved-changes-dialog') @@ -666,23 +666,23 @@ function seedUnsavedChangesReelWithBareTrack(): Post $page = openUnsavedChangesFromList($post); $page->click('@instagram-share-to-feed'); - waitForTestIdState($page, 'instagram-share-to-feed', "element.getAttribute('aria-checked') === 'false'"); + waitForState($page, '@instagram-share-to-feed', "element.getAttribute('aria-checked') === 'false'"); $page->back(); - waitForTestId($page, 'unsaved-changes-keep'); + waitFor($page, '@unsaved-changes-keep'); $page->click('@unsaved-changes-keep'); - waitForTestIdGone($page, 'unsaved-changes-dialog'); + waitForGone($page, '@unsaved-changes-dialog'); $page->script("document.querySelector('[data-testid=\"sidebar-logo\"]').click()"); - waitForTestId($page, 'unsaved-changes-discard'); + waitFor($page, '@unsaved-changes-discard'); $page->click('@unsaved-changes-discard'); - waitForTestIdGone($page, 'post-composer-dialog'); + waitForGone($page, '@post-composer-dialog'); $page->back(); - waitForTestId($page, 'instagram-share-to-feed'); + waitFor($page, '@instagram-share-to-feed'); $page->assertVisible('@post-composer-dialog') ->assertAttribute('@instagram-share-to-feed', 'aria-checked', 'true'); $page->back(); - waitForTestIdGone($page, 'post-composer-dialog'); + waitForGone($page, '@post-composer-dialog'); $page->assertMissing('@post-composer-dialog') ->assertMissing('@unsaved-changes-dialog') @@ -696,18 +696,18 @@ function seedUnsavedChangesReelWithBareTrack(): Post $listPath = unsavedChangesListPath(); $page->click('@instagram-share-to-feed'); - waitForTestIdState($page, 'instagram-share-to-feed', "element.getAttribute('aria-checked') === 'false'"); + waitForState($page, '@instagram-share-to-feed', "element.getAttribute('aria-checked') === 'false'"); $page->back(); - waitForTestId($page, 'unsaved-changes-keep'); + waitFor($page, '@unsaved-changes-keep'); $page->click('@unsaved-changes-keep'); - waitForTestIdGone($page, 'unsaved-changes-dialog'); + waitForGone($page, '@unsaved-changes-dialog'); $page->click('@composer-save-draft'); - waitForTestIdGone($page, 'post-composer-dialog'); + waitForGone($page, '@post-composer-dialog'); $page->script("document.querySelector('[data-testid=\"sidebar-logo\"]').click()"); waitForCondition($page, "location.pathname !== '{$listPath}'"); $page->back(); - waitForTestId($page, "post-edit-{$post->id}"); + waitFor($page, "@post-edit-{$post->id}"); $page->assertMissing('@unsaved-changes-dialog') ->assertPathIs($listPath); @@ -719,10 +719,10 @@ function seedUnsavedChangesReelWithBareTrack(): Post $post = seedUnsavedChangesReel(); $page = visit(route('app.posts.edit', $post))->resize(1280, 900); - waitForTestId($page, 'instagram-share-to-feed'); + waitFor($page, '@instagram-share-to-feed'); $page->click('@instagram-share-to-feed') ->keys('@instagram-share-to-feed', 'Escape'); - waitForTestId($page, 'unsaved-changes-dialog'); + waitFor($page, '@unsaved-changes-dialog'); $page->assertVisible('@unsaved-changes-dialog') ->assertVisible('@post-composer-dialog') @@ -734,14 +734,14 @@ function seedUnsavedChangesReelWithBareTrack(): Post $account = $post->socialAccount; $page = visit(route('app.posts.index'))->resize(1280, 900); - waitForTestId($page, 'posts-new-post'); + waitFor($page, '@posts-new-post'); $page->click('@posts-new-post'); - waitForTestId($page, 'composer-add-account'); + waitFor($page, '@composer-add-account'); $page->click('@composer-add-account') ->click("@composer-account-option-{$account->id}") ->fill("@composer-caption-{$account->id}", 'A new idea') ->click('@composer-close'); - waitForTestIdGone($page, 'post-composer-dialog'); + waitForGone($page, '@post-composer-dialog'); $page->assertMissing('@unsaved-changes-dialog') ->assertMissing('@post-composer-dialog') @@ -767,11 +767,11 @@ function seedUnsavedChangesReelWithBareTrack(): Post $this->actingAs($user); $page = visit(route('app.posts.edit', $post))->resize(1280, 900); - waitForCondition($page, "document.querySelector('[data-testid=\"channel-settings-rows\"] [role=\"combobox\"]:not([disabled])')?.textContent.includes('announcements')"); + waitForText($page, '@channel-settings-rows [role="combobox"]:not([disabled])', 'announcements'); expect($page->script("document.querySelector('[data-testid=\"channel-settings-rows\"] [role=\"combobox\"]').textContent.trim()"))->toBe('#announcements'); $page->click('@composer-save-draft'); - waitForTestIdGone($page, 'post-composer-dialog'); + waitForGone($page, '@post-composer-dialog'); $page->assertNoJavaScriptErrors(); expect($post->fresh()->meta)->toHaveKey('channel_id', '999') @@ -783,15 +783,15 @@ function seedUnsavedChangesReelWithBareTrack(): Post $page = openUnsavedChangesFromList($post); $page->click('@instagram-share-to-feed'); - waitForTestIdState($page, 'instagram-share-to-feed', "element.getAttribute('aria-checked') === 'false'"); + waitForState($page, '@instagram-share-to-feed', "element.getAttribute('aria-checked') === 'false'"); $page->back(); - waitForTestId($page, 'unsaved-changes-dialog'); + waitFor($page, '@unsaved-changes-dialog'); $page->back(); $page->script('new Promise((resolve) => setTimeout(resolve, 500))'); $page->assertVisible('@unsaved-changes-dialog') ->click('@unsaved-changes-keep'); - waitForTestIdGone($page, 'unsaved-changes-dialog'); + waitForGone($page, '@unsaved-changes-dialog'); $page->assertVisible('@post-composer-dialog') ->assertAttribute('@instagram-share-to-feed', 'aria-checked', 'false') @@ -802,13 +802,13 @@ function seedUnsavedChangesReelWithBareTrack(): Post $post = seedUnsavedChangesReel(); $page = visit(route('app.posts.edit', $post))->resize(1280, 900); - waitForTestId($page, 'instagram-share-to-feed'); + waitFor($page, '@instagram-share-to-feed'); $page->click('@instagram-share-to-feed'); - waitForTestIdState($page, 'instagram-share-to-feed', "element.getAttribute('aria-checked') === 'false'"); + waitForState($page, '@instagram-share-to-feed', "element.getAttribute('aria-checked') === 'false'"); $page->back(); - waitForTestId($page, 'unsaved-changes-discard'); + waitFor($page, '@unsaved-changes-discard'); $page->click('@unsaved-changes-discard'); - waitForTestIdGone($page, 'post-composer-dialog'); + waitForGone($page, '@post-composer-dialog'); $page->assertVisible('@sidebar-logo') ->assertMissing('@post-composer-dialog') @@ -822,7 +822,7 @@ function seedUnsavedChangesReelWithBareTrack(): Post $post = seedUnsavedChangesReel(); $page = visit(route('app.posts.edit', $post))->resize(1280, 900); - waitForTestId($page, 'instagram-share-to-feed'); + waitFor($page, '@instagram-share-to-feed'); $page->click('@instagram-share-to-feed'); $page->script("window.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', metaKey: true, bubbles: true, cancelable: true }))"); $page->assertMissing('@command-palette') @@ -847,7 +847,7 @@ function seedUnsavedChangesReelWithBareTrack(): Post $post = seedUnsavedChangesReel(); $page = visit(route('app.posts.edit', $post))->resize(1280, 900); - waitForTestId($page, 'instagram-share-to-feed'); + waitFor($page, '@instagram-share-to-feed'); $page->click('@instagram-share-to-feed'); $page->script(<<<'JS' (() => { @@ -858,7 +858,7 @@ function seedUnsavedChangesReelWithBareTrack(): Post outside.dispatchEvent(new MouseEvent('click', options)); })() JS); - waitForTestId($page, 'unsaved-changes-dialog'); + waitFor($page, '@unsaved-changes-dialog'); $page->assertVisible('@unsaved-changes-dialog') ->assertVisible('@post-composer-dialog') @@ -870,28 +870,28 @@ function seedUnsavedChangesReelWithBareTrack(): Post $page = openUnsavedChangesFromList($post); $page->click('@instagram-share-to-feed'); - waitForTestIdState($page, 'instagram-share-to-feed', "element.getAttribute('aria-checked') === 'false'"); + waitForState($page, '@instagram-share-to-feed', "element.getAttribute('aria-checked') === 'false'"); $arrived = Post::factory()->forAccount($post->socialAccount, ContentType::InstagramReel)->create([ 'user_id' => $post->user_id, 'content' => 'Arrived while editing', 'meta' => [], ]); $page->script("document.getElementById('app').__vue_app__.config.globalProperties.\$inertia.reload({ only: ['queue', 'posts', 'counts', 'hasData', 'channels'], reset: ['posts'] })"); - waitForTestId($page, "post-edit-{$arrived->id}"); + waitFor($page, "@post-edit-{$arrived->id}"); foreach (range(1, 2) as $attempt) { $page->back(); - waitForTestId($page, 'unsaved-changes-keep'); + waitFor($page, '@unsaved-changes-keep'); $page->click('@unsaved-changes-keep'); - waitForTestIdGone($page, 'unsaved-changes-dialog'); + waitForGone($page, '@unsaved-changes-dialog'); } $page->script("document.querySelector('[data-testid=\"sidebar-logo\"]').click()"); - waitForTestId($page, 'unsaved-changes-discard'); + waitFor($page, '@unsaved-changes-discard'); $page->click('@unsaved-changes-discard'); - waitForTestIdGone($page, 'post-composer-dialog'); + waitForGone($page, '@post-composer-dialog'); $page->back(); - waitForTestId($page, 'instagram-share-to-feed'); + waitFor($page, '@instagram-share-to-feed'); expect($page->script("Boolean(document.querySelector('[data-testid=\"post-edit-{$arrived->id}\"]'))"))->toBeTrue(); $page->assertNoJavaScriptErrors(); @@ -902,10 +902,10 @@ function seedUnsavedChangesReelWithBareTrack(): Post $page = openUnsavedChangesFromList($post, route('app.settings.preferences')); $page->click('@instagram-share-to-feed'); - waitForTestIdState($page, 'instagram-share-to-feed', "element.getAttribute('aria-checked') === 'false'"); + waitForState($page, '@instagram-share-to-feed', "element.getAttribute('aria-checked') === 'false'"); $length = $page->script('window.history.length'); $page->script('window.history.go(-2)'); - waitForTestIdGone($page, 'post-composer-dialog'); + waitForGone($page, '@post-composer-dialog'); $page->script('new Promise((resolve) => setTimeout(resolve, 800))'); $page->assertMissing('@post-composer-dialog') @@ -927,25 +927,25 @@ function seedUnsavedChangesReelWithBareTrack(): Post $page->type('@instagram-collaborator-input', 'bad-name!') ->click('@instagram-collaborator-add'); - waitForTestId($page, 'instagram-collaborators-notice'); + waitFor($page, '@instagram-collaborators-notice'); $page->click('@instagram-collaborators-notice-dismiss'); - waitForTestIdGone($page, 'instagram-collaborators-notice'); + waitForGone($page, '@instagram-collaborators-notice'); $page->click('@composer-save-draft'); - waitForCondition($page, 'document.body.textContent.includes('.json_encode(__('posts.form.instagram.collaborators.invalid')).')'); + waitForText($page, 'body', __('posts.form.instagram.collaborators.invalid')); $page->assertSee(__('posts.form.instagram.collaborators.invalid')) ->assertVisible('@post-composer-dialog') ->click('@composer-close'); - waitForTestId($page, 'unsaved-changes-keep'); + waitFor($page, '@unsaved-changes-keep'); $page->click('@unsaved-changes-keep'); - waitForTestIdGone($page, 'unsaved-changes-dialog'); + waitForGone($page, '@unsaved-changes-dialog'); $page->click('@instagram-collaborator-remove-bad-name!') ->type('@instagram-collaborator-input', 'alice') ->click('@instagram-collaborator-add'); - waitForTestId($page, 'instagram-collaborator-alice'); + waitFor($page, '@instagram-collaborator-alice'); $page->click('@composer-save-draft'); - waitForTestIdGone($page, 'post-composer-dialog'); + waitForGone($page, '@post-composer-dialog'); $page->back(); $page->script('new Promise((resolve) => setTimeout(resolve, 800))'); @@ -961,17 +961,17 @@ function seedUnsavedChangesReelWithBareTrack(): Post $page = openUnsavedChangesFromList($post, route('app.settings.preferences')); $page->click('@instagram-share-to-feed'); - waitForTestIdState($page, 'instagram-share-to-feed', "element.getAttribute('aria-checked') === 'false'"); + waitForState($page, '@instagram-share-to-feed', "element.getAttribute('aria-checked') === 'false'"); $page->back(); - waitForTestId($page, 'unsaved-changes-keep'); + waitFor($page, '@unsaved-changes-keep'); $page->click('@unsaved-changes-keep'); - waitForTestIdGone($page, 'unsaved-changes-dialog'); + waitForGone($page, '@unsaved-changes-dialog'); $page->click('@instagram-share-to-feed'); - waitForTestIdState($page, 'instagram-share-to-feed', "element.getAttribute('aria-checked') === 'true'"); + waitForState($page, '@instagram-share-to-feed', "element.getAttribute('aria-checked') === 'true'"); $page->script('new Promise((resolve) => setTimeout(resolve, 500))'); $page->click('@composer-close'); - waitForTestIdGone($page, 'post-composer-dialog'); + waitForGone($page, '@post-composer-dialog'); waitForCondition($page, "!location.search.includes('edit=')"); $page->assertMissing('@unsaved-changes-dialog') ->assertPathIs(unsavedChangesListPath()); @@ -989,10 +989,10 @@ function seedUnsavedChangesReelWithBareTrack(): Post $page = openUnsavedChangesFromList($post, route('app.settings.preferences')); $page->click('@instagram-share-to-feed'); - waitForTestIdState($page, 'instagram-share-to-feed', "element.getAttribute('aria-checked') === 'false'"); + waitForState($page, '@instagram-share-to-feed', "element.getAttribute('aria-checked') === 'false'"); $post->forceFill(['status' => PostStatus::Published, 'published_at' => now()])->save(); $page->click('@composer-save-draft'); - waitForTestIdGone($page, 'post-composer-dialog'); + waitForGone($page, '@post-composer-dialog'); waitForCondition($page, "!location.search.includes('edit=')"); $page->back(); $page->script('new Promise((resolve) => setTimeout(resolve, 800))'); @@ -1035,7 +1035,7 @@ function seedUnsavedChangesReelWithBareTrack(): Post $this->actingAs($user); $page = visit(route('app.posts.index', ['tab' => 'drafts']))->resize(1280, 900); - waitForTestId($page, "post-edit-{$post->id}"); + waitFor($page, "@post-edit-{$post->id}"); $page->script(<<<'JS' (() => { const open = XMLHttpRequest.prototype.open; @@ -1054,7 +1054,7 @@ function seedUnsavedChangesReelWithBareTrack(): Post })() JS); $page->click("@post-edit-{$post->id}"); - waitForTestId($page, 'composer-editor-column'); + waitFor($page, '@composer-editor-column'); $page->script(<<<'JS' (() => { const target = document.querySelector('[data-testid="composer-editor-column"] [contenteditable="true"]') ?? document.querySelector('[data-testid="composer-editor-column"]'); @@ -1064,9 +1064,9 @@ function seedUnsavedChangesReelWithBareTrack(): Post })() JS); expect($page->script("document.querySelector('[data-testid=\"tiktok-allow-comments\"]')?.disabled === true"))->toBeFalse(); - waitForTestIdState($page, 'tiktok-allow-comments', 'element.disabled === true'); + waitForState($page, '@tiktok-allow-comments', 'element.disabled === true'); $page->click('@composer-close'); - waitForTestIdGoneOrShown($page, gone: 'post-composer-dialog', shown: 'unsaved-changes-dialog'); + waitForGoneOrShown($page, gone: '@post-composer-dialog', shown: '@unsaved-changes-dialog'); $page->assertMissing('@unsaved-changes-dialog') ->assertMissing('@post-composer-dialog') @@ -1114,13 +1114,13 @@ function seedUnsavedChangesReelWithBareTrack(): Post input.dispatchEvent(new Event('change', { bubbles: true })); })() JS); - waitForTestId($page, "composer-{$account->id}-upload-item"); + waitFor($page, "@composer-{$account->id}-upload-item"); $page->click('@composer-close'); - waitForTestId($page, 'unsaved-changes-dialog'); + waitFor($page, '@unsaved-changes-dialog'); $page->assertVisible('@unsaved-changes-dialog') ->click('@unsaved-changes-discard'); - waitForTestIdGone($page, 'post-composer-dialog'); + waitForGone($page, '@post-composer-dialog'); $page->assertMissing('@post-composer-dialog') ->assertNoJavaScriptErrors(); @@ -1143,9 +1143,9 @@ function seedUnsavedChangesReelWithBareTrack(): Post $page->click("@composer-caption-{$account->id}") ->type("@composer-caption-{$account->id}", ' later') ->click('@composer-schedule-trigger'); - waitForTestId($page, 'composer-queue-manage-slots'); + waitFor($page, '@composer-queue-manage-slots'); $page->click('@composer-queue-manage-slots'); - waitForTestId($page, 'unsaved-changes-dialog'); + waitFor($page, '@unsaved-changes-dialog'); $page->assertVisible('@unsaved-changes-dialog') ->assertVisible('@post-composer-dialog') diff --git a/tests/Pest.php b/tests/Pest.php index ff2dbe34c9..c29c121afe 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -581,57 +581,66 @@ function waitForCondition(mixed $page, string $condition): void } /** - * The JavaScript that finds the element with this data-testid. + * The selector as a JavaScript string, with `@name` standing for + * `[data-testid="name"]` as in `$page->click('@name')`, anywhere in it: + * `@channel-settings-rows [role="combobox"]`. */ -function testIdQuery(string $testId): string +function jsSelector(string $selector): string { - $selector = json_encode("[data-testid=\"{$testId}\"]"); - - return "document.querySelector({$selector})"; + return json_encode(preg_replace('/@([\w-]+)/', '[data-testid="$1"]', $selector)); } /** - * Wait until the element with this data-testid is mounted and laid out. + * Wait until an element matching the selector exists and the expression, + * written about `element`, is true: "element.getAttribute('aria-checked') === 'true'". */ -function waitForTestId(mixed $page, string $testId): void +function waitForState(mixed $page, string $selector, string $expression): void { - $element = testIdQuery($testId); + $selector = jsSelector($selector); + + waitForCondition($page, "((element) => element && ({$expression}))(document.querySelector({$selector}))"); +} - waitForCondition($page, "{$element}?.getBoundingClientRect().height > 0"); +/** + * Wait until an element matching the selector is mounted and laid out. + */ +function waitFor(mixed $page, string $selector): void +{ + waitForState($page, $selector, 'element.getBoundingClientRect().height > 0'); } /** - * Wait until no element with this data-testid is left in the page. + * Wait until no element matches the selector. */ -function waitForTestIdGone(mixed $page, string $testId): void +function waitForGone(mixed $page, string $selector): void { - $element = testIdQuery($testId); + $selector = jsSelector($selector); - waitForCondition($page, "!{$element}"); + waitForCondition($page, "!document.querySelector({$selector})"); } /** - * Wait until the element with this data-testid exists and the expression, - * written about `element`, is true: "element.getAttribute('aria-checked') === 'true'". + * Wait until any element matching the selector contains the text. */ -function waitForTestIdState(mixed $page, string $testId, string $expression): void +function waitForText(mixed $page, string $selector, string $text): void { - $element = testIdQuery($testId); + $selector = jsSelector($selector); + $text = json_encode($text); - waitForCondition($page, "((element) => Boolean(element) && ({$expression}))({$element})"); + waitForCondition($page, "[...document.querySelectorAll({$selector})].some((element) => element.textContent.includes({$text}))"); } /** * Wait until one element is gone or another is shown, whichever happens - * first: a test can then assert the outcome it expects without waiting out - * the timeout when the other one happens. + * first, so the assertion after it fails at once on the other outcome + * instead of waiting out the timeout. */ -function waitForTestIdGoneOrShown(mixed $page, string $gone, string $shown): void +function waitForGoneOrShown(mixed $page, string $gone, string $shown): void { - $goneElement = testIdQuery($gone); - $shownElement = testIdQuery($shown); + $gone = jsSelector($gone); + $shown = jsSelector($shown); - waitForCondition($page, "!{$goneElement} || {$shownElement}"); + waitForCondition($page, "!document.querySelector({$gone}) || document.querySelector({$shown})"); } /** @@ -639,7 +648,7 @@ function waitForTestIdGoneOrShown(mixed $page, string $gone, string $shown): voi */ function waitForPath(mixed $page, string $path): void { - $expected = json_encode($path); + $path = json_encode($path); - waitForCondition($page, "location.pathname === {$expected}"); + waitForCondition($page, "location.pathname === {$path}"); } From 4fede33a780d8703e35009dace8b20577368c9cb Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Fri, 16 Oct 2026 15:03:05 -0300 Subject: [PATCH 5/5] Name the missing track details check plainly --- resources/js/components/posts/editor/InstagramSettings.vue | 7 +++---- .../js/composables/composer/useComposerUnsavedChanges.ts | 6 +++--- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/resources/js/components/posts/editor/InstagramSettings.vue b/resources/js/components/posts/editor/InstagramSettings.vue index 6d01db89c8..aa0fe5c6a0 100644 --- a/resources/js/components/posts/editor/InstagramSettings.vue +++ b/resources/js/components/posts/editor/InstagramSettings.vue @@ -235,14 +235,13 @@ const refreshSelectedAudio = async (): Promise => { } }; -const missesTrackDetails = (audio: InstagramAudio | null): boolean => - Boolean(audio?.id) && - (!audio?.title || !audio.cover_url || !audio.preview_url); +const isMissingTrackDetails = (audio: InstagramAudio | null): boolean => + Boolean(audio?.id && (!audio.title || !audio.cover_url || !audio.preview_url)); watch( isReel, (reel) => { - if (reel && missesTrackDetails(selectedAudio.value)) { + if (reel && isMissingTrackDetails(selectedAudio.value)) { void refreshSelectedAudio(); } }, diff --git a/resources/js/composables/composer/useComposerUnsavedChanges.ts b/resources/js/composables/composer/useComposerUnsavedChanges.ts index 831a3dfd10..0a80f89e8a 100644 --- a/resources/js/composables/composer/useComposerUnsavedChanges.ts +++ b/resources/js/composables/composer/useComposerUnsavedChanges.ts @@ -19,9 +19,9 @@ interface ComposerUnsavedChangesOptions { } /** - * The meta with only what the person chooses of an Instagram track: the track - * and its volumes. Its name, cover and preview come with the id and are filled - * in or renewed by the composer on its own, so they never count as a change. + * The meta as the guard compares it: an Instagram track counts by its id and + * volumes only. Its name, cover and preview come with the id, and the composer + * fills them in or renews them on its own, so they never count as a change. */ const withoutTrackDetails = (meta: Record): Record => { const audio = meta.audio as InstagramAudio | null | undefined;