diff --git a/AGENTS.md b/AGENTS.md index 662de79a3d..4d13bab541 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1025,8 +1025,17 @@ 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: + - 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 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()`. - Each `visit()` opens a new browser context: `localStorage`, cookies and history are not shared with the previous visit. 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..aa0fe5c6a0 100644 --- a/resources/js/components/posts/editor/InstagramSettings.vue +++ b/resources/js/components/posts/editor/InstagramSettings.vue @@ -222,8 +222,10 @@ const refreshSelectedAudio = async (): Promise => { 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,19 @@ const refreshSelectedAudio = async (): Promise => { } }; +const isMissingTrackDetails = (audio: InstagramAudio | null): boolean => + Boolean(audio?.id && (!audio.title || !audio.cover_url || !audio.preview_url)); + +watch( + isReel, + (reel) => { + if (reel && isMissingTrackDetails(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..0a80f89e8a 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,29 +18,57 @@ interface ComposerUnsavedChangesOptions { uploading: () => boolean; } +/** + * 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; + + 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, ) => { 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( + 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: withoutTrackDetails(destination.meta), + })), + }, scheduleMode: mode, }); }; diff --git a/tests/Browser/ComposerChannelSettingsTest.php b/tests/Browser/ComposerChannelSettingsTest.php index a40a1482aa..4037e01caa 100644 --- a/tests/Browser/ComposerChannelSettingsTest.php +++ b/tests/Browser/ComposerChannelSettingsTest.php @@ -47,36 +47,11 @@ 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(); } -function waitForChannelSettingsClosedMenu(mixed $page): void -{ - waitForChannelSettingsCondition($page, "!document.querySelector('[role=\"listbox\"]')"); -} - function fakeChannelSettingsApis(): void { $boards = [ @@ -106,7 +81,7 @@ function fakeChannelSettingsApis(): void $post = seedChannelSettingsPost($platform, $contentType, $media); $page = visit(route('app.posts.edit', $post))->resize(1280, 900); - waitForChannelSettingsTestId($page, 'channel-settings-rows'); + waitFor($page, '@channel-settings-rows'); $layout = $page->script(<<<'JS' (() => { @@ -164,7 +139,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"); + 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') @@ -180,7 +155,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'"); + waitForState($page, "@composer-type-{$id}-instagram_reel", "element.getAttribute('aria-checked') === 'true'"); $page->assertAttribute("@composer-type-{$id}-instagram_reel", 'aria-checked', 'true') ->assertNoJavaScriptErrors(); @@ -196,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); - waitForChannelSettingsTestId($page, 'pinterest-board-trigger'); + waitFor($page, '@pinterest-board-trigger'); $page->assertMissing("@composer-type-{$id}") ->assertNoJavaScriptErrors() @@ -211,27 +186,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'); + waitFor($page, '@pinterest-board-trigger'); $page->click('@pinterest-board-trigger'); - waitForChannelSettingsTestId($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'); - waitForChannelSettingsCondition($page, "!document.querySelector('[data-testid=\"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'); - waitForChannelSettingsTestId($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'); - waitForChannelSettingsTestId($page, 'pinterest-board-selected'); + waitFor($page, '@pinterest-board-selected'); $page->assertSeeIn('@pinterest-board-selected', 'Recipes') ->assertMissing('@pinterest-board-picker') ->assertNoJavaScriptErrors(); @@ -241,15 +216,15 @@ function fakeChannelSettingsApis(): void && $request->data() === ['name' => 'Recipes']); $page->click('@pinterest-board-trigger'); - waitForChannelSettingsTestId($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'); - waitForChannelSettingsCondition($page, "!document.querySelector('[data-testid=\"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'); - waitForChannelSettingsCondition($page, "!document.querySelector('[data-testid=\"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'); @@ -260,7 +235,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'); + waitFor($page, '@google-business-topic-STANDARD'); $fields = <<<'JS' [...document.querySelectorAll('[data-testid="composer-customization"] [data-testid^="google-business-"]')] .map((node) => node.dataset.testid) @@ -274,29 +249,29 @@ function fakeChannelSettingsApis(): void ->and($page->script($fields))->toBe(['google-business-cta']); $page->click('@google-business-topic-OFFER'); - waitForChannelSettingsTestId($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'); - waitForChannelSettingsTestId($page, 'google-business-cta'); + waitFor($page, '@google-business-cta'); $page->click('@google-business-cta'); - waitForChannelSettingsTestId($page, 'google-business-cta-option-BOOK'); + waitFor($page, '@google-business-cta-option-BOOK'); $page->click('@google-business-cta-option-BOOK'); - waitForChannelSettingsTestId($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'); - waitForChannelSettingsTestId($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'); - waitForChannelSettingsCondition($page, "!document.querySelector('[data-testid=\"google-business-coupon\"]')"); + waitForGone($page, '@google-business-coupon'); $page->assertMissing('@google-business-redeem'); $page->click('@google-business-topic-EVENT'); - waitForChannelSettingsTestId($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']); @@ -320,14 +295,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'); + waitFor($page, '@google-business-cta'); $page->click('@google-business-cta'); - waitForChannelSettingsTestId($page, 'google-business-cta-option-BOOK'); + waitFor($page, '@google-business-cta-option-BOOK'); $page->click('@google-business-cta-option-BOOK'); - waitForChannelSettingsTestId($page, 'google-business-cta-url'); + waitFor($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'"); + 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")) @@ -343,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); - waitForChannelSettingsTestId($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'); - waitForChannelSettingsTestId($page, 'google-business-start-time'); + waitFor($page, '@google-business-start-time'); $layout = $page->script(<<<'JS' (() => { @@ -383,7 +358,7 @@ function fakeChannelSettingsApis(): void $post = seedChannelSettingsPost(Platform::YouTube, ContentType::YouTubeShort); $page = visit(route('app.posts.edit', $post))->resize(1280, 900); - waitForChannelSettingsTestId($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']; @@ -404,17 +379,17 @@ function fakeChannelSettingsApis(): void ->assertValue('@youtube-title', 'Channel settings') ->fill('@youtube-title', 'Explicit title') ->click('@youtube-category'); - waitForChannelSettingsTestId($page, 'youtube-category-27'); + waitFor($page, '@youtube-category-27'); $page->click('@youtube-category-27'); - waitForChannelSettingsClosedMenu($page); + waitForGone($page, '[role="listbox"]'); $page->click('@youtube-privacy'); - waitForChannelSettingsTestId($page, 'youtube-privacy-unlisted'); + waitFor($page, '@youtube-privacy-unlisted'); $page->click('@youtube-privacy-unlisted'); - waitForChannelSettingsClosedMenu($page); + waitForGone($page, '[role="listbox"]'); $page->click('@youtube-license'); - waitForChannelSettingsTestId($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') @@ -439,7 +414,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'); + waitFor($page, '@tiktok-ai-generated'); expect($page->script(<<<'JS' (() => { @@ -467,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); - waitForChannelSettingsTestId($page, 'google-business-topic-EVENT'); + waitFor($page, '@google-business-topic-EVENT'); $page->click('@google-business-topic-EVENT'); - waitForChannelSettingsTestId($page, 'google-business-add-time'); + waitFor($page, '@google-business-add-time'); $page->fill('@google-business-title', 'Launch night') ->click('@google-business-add-time'); - waitForChannelSettingsTestId($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'); - waitForChannelSettingsTestId($page, 'google-business-add-details'); + waitFor($page, '@google-business-add-details'); $page->assertMissing('@google-business-start-time') ->assertNoJavaScriptErrors() @@ -500,7 +475,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'); + 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' @@ -523,13 +498,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'); + waitForGone($page, '@instagram-share-to-feed'); + waitFor($page, '@instagram-ai-generated'); $page->click("@composer-type-{$id}-instagram_feed"); - waitForChannelSettingsTestId($page, 'instagram-ai-generated'); + waitFor($page, '@instagram-ai-generated'); $page->assertMissing('@instagram-share-to-feed') ->click("@composer-type-{$id}-instagram_reel"); - waitForChannelSettingsTestId($page, 'instagram-share-to-feed'); + waitFor($page, '@instagram-share-to-feed'); $page->assertNoJavaScriptErrors(); $page->click('@composer-save-draft')->assertMissing('@post-composer-dialog'); @@ -545,39 +520,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'); + waitFor($page, '@instagram-music-add'); $page->click('@instagram-music-add'); - waitForChannelSettingsTestId($page, 'instagram-music-link-facebook'); + waitFor($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\"]')"); + waitForGone($page, '@instagram-music-link-facebook'); $page->type('@instagram-collaborator-input', '@alice') ->keys('@instagram-collaborator-input', 'Enter'); - waitForChannelSettingsTestId($page, 'instagram-collaborators-notice'); + waitFor($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\"]')"); + waitForGone($page, '@instagram-collaborators-notice'); $page->type('@instagram-collaborator-input', 'bob') ->click('@instagram-collaborator-add'); - waitForChannelSettingsTestId($page, 'instagram-collaborator-bob'); + waitFor($page, '@instagram-collaborator-bob'); $page->click('@instagram-collaborator-remove-alice') ->click('@instagram-collaborator-remove-bob'); - waitForChannelSettingsTestId($page, 'instagram-trial-reel-MANUAL'); + waitFor($page, '@instagram-trial-reel-MANUAL'); $page->click('@instagram-trial-reel-MANUAL'); - waitForChannelSettingsTestId($page, 'instagram-trial-reel-check'); + waitFor($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\"]')"); + waitForGone($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'); + waitFor($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'"); + waitForState($page, '@instagram-trial-reel-MANUAL', "element.getAttribute('aria-checked') === 'true'"); $page->assertNoJavaScriptErrors(); $page->click('@composer-save-draft')->assertMissing('@post-composer-dialog'); @@ -593,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); - waitForChannelSettingsTestId($page, 'instagram-music-add'); + waitFor($page, '@instagram-music-add'); $page->click('@instagram-music-add'); - waitForChannelSettingsTestId($page, 'instagram-music-save-and-link'); + waitFor($page, '@instagram-music-save-and-link'); $page->click('@instagram-music-save-and-link'); - waitForChannelSettingsTestId($page, 'instagram-facebook-requirements'); + waitFor($page, '@instagram-facebook-requirements'); $page->assertMissing('@post-composer-dialog') ->assertPresent('@instagram-facebook-requirements-connect') @@ -612,7 +587,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 +599,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'); + waitFor($page, '@composer-schedule-trigger'); $page->click('@composer-schedule-trigger'); - waitForChannelSettingsTestId($page, 'composer-schedule-more-actions'); + waitFor($page, '@composer-schedule-more-actions'); $page->click('@composer-schedule-more-actions'); - waitForChannelSettingsTestId($page, 'composer-schedule-now'); + waitFor($page, '@composer-schedule-now'); $page->click('@composer-schedule-now'); - waitForChannelSettingsCondition($page, "document.querySelector('[data-testid=\"composer-submit\"]')?.dataset.scheduleMode === 'now'"); + waitForState($page, '@composer-submit', "element.dataset.scheduleMode === 'now'"); $page->click('@instagram-music-add'); - waitForChannelSettingsTestId($page, 'instagram-music-save-and-link'); + waitFor($page, '@instagram-music-save-and-link'); $page->click('@instagram-music-save-and-link'); - waitForChannelSettingsTestId($page, 'instagram-facebook-requirements'); + waitFor($page, '@instagram-facebook-requirements'); $page->assertNoJavaScriptErrors(); expect($post->fresh()->status)->toBe(Status::Draft); @@ -647,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); - waitForChannelSettingsTestId($page, 'instagram-music-add'); + waitFor($page, '@instagram-music-add'); $page->click('@instagram-music-add'); - waitForChannelSettingsTestId($page, 'instagram-music-save-and-link'); + waitFor($page, '@instagram-music-save-and-link'); $page->click('@instagram-music-save-and-link'); - waitForChannelSettingsTestId($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') @@ -669,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); - waitForChannelSettingsTestId($page, 'instagram-music-add'); + waitFor($page, '@instagram-music-add'); $page->click('@instagram-music-add'); - waitForChannelSettingsTestId($page, 'instagram-music-save-and-link'); + waitFor($page, '@instagram-music-save-and-link'); $page->click('@instagram-music-save-and-link'); - waitForChannelSettingsTestId($page, 'instagram-facebook-requirements'); + waitFor($page, '@instagram-facebook-requirements'); $page->assertNoJavaScriptErrors(); expect($post->fresh()->status)->toBe(Status::PendingApproval); @@ -686,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); - waitForChannelSettingsTestId($page, 'instagram-music-add'); + waitFor($page, '@instagram-music-add'); $page->click('@instagram-music-add'); - waitForChannelSettingsTestId($page, 'instagram-music-save-and-link'); + waitFor($page, '@instagram-music-save-and-link'); $page->click('@instagram-music-save-and-link'); - waitForChannelSettingsTestId($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') @@ -703,12 +678,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'); + waitFor($page, '@instagram-music-add'); $page->click('@instagram-music-add'); - waitForChannelSettingsTestId($page, 'instagram-music-save-and-link'); + waitFor($page, '@instagram-music-save-and-link'); $page->click('@instagram-music-save-and-link'); - waitForChannelSettingsTestId($page, 'instagram-facebook-requirements'); + waitFor($page, '@instagram-facebook-requirements'); $page->assertMissing('@post-composer-dialog')->assertNoJavaScriptErrors(); expect($post->fresh()->status)->toBe(Status::Draft); @@ -719,18 +694,18 @@ function fakeChannelSettingsApis(): void $post = seedChannelSettingsPost(Platform::Instagram, ContentType::InstagramReel); $page = visit(route('app.posts.edit', $post))->resize(1280, 900); - waitForChannelSettingsTestId($page, 'instagram-collaborator-input'); + waitFor($page, '@instagram-collaborator-input'); $page->type('@instagram-collaborator-input', 'good_user') ->keys('@instagram-collaborator-input', 'Enter'); - waitForChannelSettingsTestId($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'); - waitForChannelSettingsTestId($page, 'instagram-collaborator-bad-name!'); + waitFor($page, '[data-testid="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')).')'); + 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(); @@ -754,8 +729,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')"); + 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(); @@ -773,8 +748,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\"]')"); + waitFor($page, '@instagram-music-selected'); + waitForGone($page, '@instagram-music-selected-cover'); $page->assertMissing('@instagram-music-selected-cover') ->assertSeeIn('@instagram-music-selected', 'Summer Song'); @@ -792,19 +767,29 @@ function fakeChannelSettingsApis(): void $post = seedChannelSettingsPost(Platform::InstagramFacebook, ContentType::InstagramReel); $page = visit(route('app.posts.edit', $post))->resize(1280, 900); - waitForChannelSettingsTestId($page, 'instagram-music-add'); + waitFor($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'); + waitFor($page, '@instagram-music-track-1234567890'); $page->click('@instagram-music-track-1234567890'); - waitForChannelSettingsTestId($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'); - waitForChannelSettingsTestId($page, 'instagram-music-video_volume'); + waitFor($page, '@instagram-music-video_volume'); $page->script(<<<'JS' (() => { const slider = document.querySelector('[data-testid="instagram-music-video_volume"]'); @@ -812,10 +797,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%')"); + waitForText($page, '@instagram-music-volume-panel', '30%'); $page->assertSeeIn('@instagram-music-volume-panel', '30%') ->keys('@instagram-music-video_volume', 'Escape'); - waitForChannelSettingsCondition($page, "!document.querySelector('[data-testid=\"instagram-music-volume-panel\"]')"); + waitForGone($page, '@instagram-music-volume-panel'); $page->click('@composer-save-draft')->assertMissing('@post-composer-dialog'); @@ -826,7 +811,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 () { @@ -835,7 +820,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}"); + waitFor($page, "@composer-hashtags-remaining-{$id}"); expect($page->script(<< { @@ -852,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'); - waitForChannelSettingsTestId($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'); - waitForChannelSettingsCondition($page, "!document.querySelector('[data-testid=\"composer-hashtag-warning-{$id}\"]')"); + waitForGone($page, "@composer-hashtag-warning-{$id}"); $page->assertSeeIn("@composer-hashtags-remaining-{$id}", '0') ->assertNoJavaScriptErrors(); @@ -870,7 +855,7 @@ function fakeChannelSettingsApis(): void $limit = Platform::Mastodon->maxContentLength(); $page = visit(route('app.posts.edit', $post))->resize(1280, 900); - waitForChannelSettingsTestId($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') @@ -889,19 +874,19 @@ function fakeChannelSettingsApis(): void $id = $post->social_account_id; $page = visit(route('app.posts.edit', $post))->resize(1280, 900); - waitForChannelSettingsTestId($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"); - waitForChannelSettingsCondition($page, "!document.querySelector('[data-testid=\"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"); - waitForChannelSettingsCondition($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"); - waitForChannelSettingsTestId($page, 'threads-topic-tag'); + waitFor($page, '@threads-topic-tag'); $page->assertVisible("@composer-{$id}-media-source-menu") ->assertMissing("@composer-{$id}-media-source-disabled") ->assertNoJavaScriptErrors(); @@ -923,12 +908,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}"); + waitFor($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}\"]')"); + waitForGone($page, "@composer-link-card-{$id}"); $page->assertMissing("@composer-text-only-warning-{$id}") ->assertNoJavaScriptErrors(); @@ -944,24 +929,24 @@ function fakeChannelSettingsApis(): void $limit = Platform::Bluesky->maxContentLength(); $page = visit(route('app.posts.edit', $post))->resize(1280, 900); - waitForChannelSettingsTestId($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'); - waitForChannelSettingsTestId($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'); - waitForChannelSettingsTestId($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'); - waitForChannelSettingsCondition($page, "!document.querySelector('[data-testid=\"thread-reply-1\"], [data-testid=\"thread-reply-collapsed-1\"]')"); + waitForGone($page, '@thread-reply-1, @thread-reply-collapsed-1'); $page->click("@composer-{$id}-caption-collapsed"); - waitForChannelSettingsTestId($page, "composer-caption-{$id}"); + waitFor($page, "@composer-caption-{$id}"); $page->assertNoJavaScriptErrors(); $page->click('@composer-save-draft')->assertMissing('@post-composer-dialog'); @@ -974,11 +959,11 @@ function fakeChannelSettingsApis(): void $post = seedChannelSettingsPost(Platform::Bluesky, ContentType::BlueskyPost); $page = visit(route('app.posts.edit', $post))->resize(1280, 900); - waitForChannelSettingsTestId($page, 'thread-start'); + waitFor($page, '@thread-start'); $page->click('@thread-start'); - waitForChannelSettingsTestId($page, 'thread-reply-0'); + waitFor($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'"); + 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(); @@ -996,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); - waitForChannelSettingsTestId($page, 'thread-start'); + waitFor($page, '@thread-start'); $page->click('@thread-start'); - waitForChannelSettingsTestId($page, 'thread-reply-0'); + waitFor($page, '@thread-reply-0'); $page->fill('@thread-reply-0', 'Fine')->click('@thread-add-reply'); - waitForChannelSettingsTestId($page, 'thread-reply-1'); + waitFor($page, '@thread-reply-1'); $page->fill('@thread-reply-1', 'Too long for the server')->click('@composer-submit'); - waitForChannelSettingsTestId($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') @@ -1017,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); - waitForChannelSettingsTestId($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") @@ -1026,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'); - waitForChannelSettingsTestId($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"); @@ -1053,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); - waitForChannelSettingsTestId($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'))); @@ -1070,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); - waitForChannelSettingsTestId($page, 'thread-start'); + waitFor($page, '@thread-start'); $page->click('@thread-start'); - waitForChannelSettingsTestId($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'))); - waitForChannelSettingsTestId($page, 'preview-thread-reply-0'); + waitFor($page, '@preview-thread-reply-0'); $page->assertSeeIn('@preview-thread-reply-0', 'Second tweet') ->assertNoJavaScriptErrors(); @@ -1093,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); - waitForChannelSettingsTestId($page, 'x-ai-generated'); + waitFor($page, '@x-ai-generated'); $page->assertVisible('@x-ai-generated-info') ->click('@x-ai-generated') @@ -1110,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); - waitForChannelSettingsTestId($page, "composer-caption-{$id}"); + waitFor($page, "@composer-caption-{$id}"); $page->keys("@composer-caption-{$id}", 'Shift+Enter'); - waitForChannelSettingsTestId($page, 'thread-reply-0'); + waitFor($page, '@thread-reply-0'); $page->fill('@thread-reply-0', 'Second tweet') ->keys('@thread-reply-0', 'Shift+Enter'); - waitForChannelSettingsTestId($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'); - waitForChannelSettingsTestId($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'); - waitForChannelSettingsTestId($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') @@ -1144,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); - waitForChannelSettingsTestId($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'); - waitForChannelSettingsTestId($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'); - waitForChannelSettingsTestId($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'); - waitForChannelSettingsTestId($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'); - waitForChannelSettingsTestId($page, "composer-caption-{$id}"); + waitFor($page, "@composer-caption-{$id}"); expect($page->script('document.activeElement?.dataset.testid'))->toBe("composer-caption-{$id}"); $page->assertNoJavaScriptErrors(); @@ -1179,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); - waitForChannelSettingsTestId($page, 'thread-reply-collapsed-2'); + waitFor($page, '@thread-reply-collapsed-2'); $page->click('@thread-reply-collapsed-2'); - waitForChannelSettingsCondition($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(); }); @@ -1198,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); - waitForChannelSettingsTestId($page, 'composer-save-draft'); + waitFor($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\"]')"); + waitForGone($page, '@composer-save-draft'); expect($post->fresh()->status)->toBe(Status::Draft); $page->assertNoJavaScriptErrors(); @@ -1213,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); - waitForChannelSettingsTestId($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(); @@ -1227,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); - waitForChannelSettingsTestId($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'); - waitForChannelSettingsCondition($page, "!document.querySelector('[data-testid=\"composer-meta-warning-{$id}\"]')"); + waitForGone($page, "@composer-meta-warning-{$id}"); $page->assertMissing("@composer-meta-warning-{$id}") ->assertNoJavaScriptErrors(); @@ -1242,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); - waitForChannelSettingsTestId($page, 'tiktok-privacy-level'); + waitFor($page, '@tiktok-privacy-level'); $page->assertMissing('@tiktok-posting-to') ->assertVisible('@tiktok-disclose') @@ -1269,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); - waitForChannelSettingsTestId($page, 'tiktok-privacy-level'); + waitFor($page, '@tiktok-privacy-level'); $page->assertMissing("@composer-type-{$id}-tiktok_video") ->assertMissing("@composer-type-{$id}-tiktok_photo") @@ -1278,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'); - waitForChannelSettingsCondition($page, "document.querySelector('[data-testid=\"tiktok-auto-add-music\"]')?.dataset.state === 'checked'"); + waitForState($page, '@tiktok-auto-add-music', "element.dataset.state === 'checked'"); $page->click('@composer-save-draft'); - waitForChannelSettingsCondition($page, "!document.querySelector('[data-testid=\"post-composer-dialog\"]')"); + waitForGone($page, '@post-composer-dialog'); expect($post->fresh()->meta['auto_add_music'] ?? null)->toBeTrue(); $page->assertNoJavaScriptErrors(); @@ -1295,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); - waitForChannelSettingsTestId($page, "composer-media-warning-{$id}"); + waitFor($page, "@composer-media-warning-{$id}"); $page->assertSeeIn("@composer-media-warning-{$id}", __("posts.form.warnings.{$warning}", $params)) ->assertNoJavaScriptErrors(); @@ -1311,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); - waitForChannelSettingsTestId($page, "composer-{$id}-media-item-1"); + waitFor($page, "@composer-{$id}-media-item-1"); foreach ([ "composer-{$id}-drag-handle-0" => 'reorder', @@ -1321,9 +1301,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\"]')"); + waitForGone($page, '[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}")).'))'); + 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}")); @@ -1338,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); - waitForChannelSettingsTestId($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); @@ -1352,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); - waitForChannelSettingsTestId($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'); - waitForChannelSettingsTestId($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'); @@ -1386,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); - waitForChannelSettingsTestId($page, 'preview-media-collage'); + waitFor($page, '@preview-media-collage'); $layout = $page->script(<<<'JS' (() => { @@ -1412,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); - waitForChannelSettingsTestId($page, $testId); + waitFor($page, "@{$testId}"); $look = $page->script(<< { @@ -1455,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); - waitForChannelSettingsTestId($page, 'instagram-music-add'); + waitFor($page, '@instagram-music-add'); $page->click('@instagram-music-add'); - waitForChannelSettingsTestId($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'); - waitForChannelSettingsCondition($page, "!document.querySelector('[data-testid=\"instagram-music-picker\"]')"); + waitForGone($page, '@instagram-music-picker'); $page->assertNoJavaScriptErrors(); }); @@ -1476,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); - waitForChannelSettingsTestId($page, 'instagram-music-add'); + waitFor($page, '@instagram-music-add'); $page->click('@instagram-music-add'); - waitForChannelSettingsTestId($page, 'instagram-music-track-audio-trending'); + waitFor($page, '@instagram-music-track-audio-trending'); $page->type('@instagram-music-search', 'grudge'); - waitForChannelSettingsTestId($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()) @@ -1503,22 +1483,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'); + 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'); - waitForChannelSettingsCondition($page, "document.querySelector('[data-testid=\"instagram-music-selected-preview\"]')?.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'); - waitForChannelSettingsCondition($page, "document.querySelector('[data-testid=\"instagram-music-selected-preview\"]')?.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'); - waitForChannelSettingsTestId($page, 'instagram-music-add'); + waitFor($page, '@instagram-music-add'); $page->assertMissing('@instagram-music-selected') ->click('@composer-save-draft'); - waitForChannelSettingsCondition($page, "!document.querySelector('[data-testid=\"post-composer-dialog\"]')"); + waitForGone($page, '@post-composer-dialog'); $page->assertNoJavaScriptErrors(); expect(data_get($post->fresh()->meta, 'audio'))->toBeNull(); @@ -1530,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); - waitForChannelSettingsTestId($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')) @@ -1542,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); - waitForChannelSettingsTestId($page, 'instagram-collaborator-input'); + waitFor($page, '@instagram-collaborator-input'); $page->type('@instagram-collaborator-input', '@alice') ->keys('@instagram-collaborator-input', 'Enter'); - waitForChannelSettingsTestId($page, 'instagram-collaborators-notice'); + waitFor($page, '@instagram-collaborators-notice'); $page->click('@instagram-collaborators-notice-dismiss') ->type('@instagram-collaborator-input', 'bob') ->click('@instagram-collaborator-add'); - waitForChannelSettingsTestId($page, 'instagram-collaborator-bob'); + waitFor($page, '@instagram-collaborator-bob'); $page->click('@composer-save-draft'); - waitForChannelSettingsCondition($page, "!document.querySelector('[data-testid=\"post-composer-dialog\"]')"); + waitForGone($page, '@post-composer-dialog'); $page->assertNoJavaScriptErrors(); expect(data_get($post->fresh()->meta, 'collaborators'))->toBe(['alice', 'bob']); @@ -1563,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); - waitForChannelSettingsTestId($page, 'instagram-collaborator-input'); + waitFor($page, '@instagram-collaborator-input'); $page->type('@instagram-collaborator-input', '@alice') ->click('@composer-save-draft'); - waitForChannelSettingsCondition($page, "!document.querySelector('[data-testid=\"post-composer-dialog\"]')"); + waitForGone($page, '@post-composer-dialog'); $page->assertNoJavaScriptErrors(); expect(data_get($post->fresh()->meta, 'collaborators'))->toBe(['alice']); @@ -1586,8 +1566,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')"); + 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}`; @@ -1610,14 +1590,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(); @@ -1636,8 +1616,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')"); + 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}`; @@ -1650,7 +1630,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'); @@ -1663,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); - waitForChannelSettingsTestId($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 c7362ebbe2..dd95f11bbf 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'); + waitFor($page, '@instagram-share-to-feed'); $page->click('@composer-close'); - waitForUnsavedChangesCondition($page, "!document.querySelector('[data-testid=\"post-composer-dialog\"]')"); + waitForGone($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'); + waitFor($page, '@instagram-share-to-feed'); $page->click('@instagram-share-to-feed') ->click('@composer-close'); - waitForUnsavedChangesTestId($page, 'unsaved-changes-dialog'); + waitFor($page, '@unsaved-changes-dialog'); $page->click('@unsaved-changes-keep'); - waitForUnsavedChangesCondition($page, "!document.querySelector('[data-testid=\"unsaved-changes-dialog\"]')"); + waitForGone($page, '@unsaved-changes-dialog'); $page->assertVisible('@post-composer-dialog') ->click('@composer-close'); - waitForUnsavedChangesTestId($page, 'unsaved-changes-discard'); + waitFor($page, '@unsaved-changes-discard'); $page->click('@unsaved-changes-discard'); - waitForUnsavedChangesCondition($page, "!document.querySelector('[data-testid=\"post-composer-dialog\"]')"); + waitForGone($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'); + waitFor($page, '@instagram-share-to-feed'); $page->click('@instagram-share-to-feed') ->click('@composer-save-draft'); - waitForUnsavedChangesCondition($page, "!document.querySelector('[data-testid=\"post-composer-dialog\"]')"); + waitForGone($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')"); + waitForGone($page, '@post-composer-dialog'); + waitForCondition($page, "!location.search.includes('edit=')"); + waitForText($page, "@post-card-{$post->id}", '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')"); + waitForGone($page, '@post-composer-dialog'); + waitForCondition($page, "!location.search.includes('edit=')"); + waitForText($page, "@post-card-{$post->id}", '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=')"); + waitForGone($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'); + waitFor($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=')"); + waitForGone($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}"); + waitFor($page, "@calendar-post-{$post->id}"); $page->click("@calendar-post-{$post->id}"); } - waitForUnsavedChangesTestId($page, "post-edit-{$post->id}"); + waitFor($page, "@post-edit-{$post->id}"); $page->click("@post-edit-{$post->id}"); - waitForUnsavedChangesTestId($page, 'instagram-share-to-feed'); + waitFor($page, '@instagram-share-to-feed'); $page->click('@instagram-share-to-feed') ->click('@composer-save-draft'); - waitForUnsavedChangesCondition($page, "!document.querySelector('[data-testid=\"post-composer-dialog\"]')"); + waitForGone($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\"]')"); + waitForGone($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'); + 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"]'); @@ -279,10 +269,10 @@ function waitForUnsavedChangesTestId(mixed $page, string $testId): void })() JS); } - waitForUnsavedChangesTestId($page, 'channel-settings-rows'); + waitFor($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\"]')"); + waitForGoneOrShown($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'); + waitFor($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\"]')"); + waitForGone($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'); + waitFor($page, '@composer-schedule-trigger'); $page->click('@composer-schedule-trigger'); - waitForUnsavedChangesTestId($page, 'composer-schedule-more-actions'); + waitFor($page, '@composer-schedule-more-actions'); $page->click('@composer-schedule-more-actions'); - waitForUnsavedChangesTestId($page, 'composer-schedule-now'); + waitFor($page, '@composer-schedule-now'); $page->click('@composer-schedule-now'); - waitForUnsavedChangesCondition($page, "document.querySelector('[data-testid=\"composer-submit\"]')?.dataset.scheduleMode === 'now'"); + waitForState($page, '@composer-submit', "element.dataset.scheduleMode === 'now'"); $page->click('@composer-close'); - waitForUnsavedChangesTestId($page, 'unsaved-changes-dialog'); + waitFor($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])')"); + waitFor($page, '@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'))"); + waitForText($page, '[role="option"]', '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')"); + waitForText($page, '@channel-settings-rows [role="combobox"]', 'general'); $page->click('@composer-save-draft'); - waitForUnsavedChangesCondition($page, "!document.querySelector('[data-testid=\"post-composer-dialog\"]')"); + waitForGone($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'); + waitFor($page, '@instagram-share-to-feed'); $page->click('@instagram-share-to-feed') ->script("document.querySelector('[data-testid=\"sidebar-logo\"]').click()"); - waitForUnsavedChangesTestId($page, 'unsaved-changes-dialog'); + waitFor($page, '@unsaved-changes-dialog'); $page->assertVisible('@post-composer-dialog') ->click('@unsaved-changes-keep'); - waitForUnsavedChangesCondition($page, "!document.querySelector('[data-testid=\"unsaved-changes-dialog\"]')"); + waitForGone($page, '@unsaved-changes-dialog'); $page->assertVisible('@post-composer-dialog') ->script("document.querySelector('[data-testid=\"sidebar-logo\"]').click()"); - waitForUnsavedChangesTestId($page, 'unsaved-changes-discard'); + waitFor($page, '@unsaved-changes-discard'); $page->click('@unsaved-changes-discard'); - waitForUnsavedChangesCondition($page, "!document.querySelector('[data-testid=\"post-composer-dialog\"]')"); + waitForGone($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}"); + waitFor($page, "@post-edit-{$post->id}"); $page->click("@post-edit-{$post->id}"); - waitForUnsavedChangesTestId($page, $readyTestId); + waitFor($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'"); + waitForState($page, '@instagram-share-to-feed', "element.getAttribute('aria-checked') === 'false'"); $page->back(); - waitForUnsavedChangesTestId($page, 'unsaved-changes-dialog'); + waitFor($page, '@unsaved-changes-dialog'); $page->assertVisible('@post-composer-dialog') ->click('@unsaved-changes-keep'); - waitForUnsavedChangesCondition($page, "!document.querySelector('[data-testid=\"unsaved-changes-dialog\"]')"); + waitForGone($page, '@unsaved-changes-dialog'); $page->assertVisible('@post-composer-dialog') ->assertAttribute('@instagram-share-to-feed', 'aria-checked', 'false'); $page->back(); - waitForUnsavedChangesTestId($page, 'unsaved-changes-discard'); + waitFor($page, '@unsaved-changes-discard'); $page->click('@unsaved-changes-discard'); - waitForUnsavedChangesCondition($page, "!document.querySelector('[data-testid=\"post-composer-dialog\"]')"); + waitForGone($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\"]')"); + waitForGone($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=')"); + waitForGone($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'); + waitFor($page, '@unsaved-changes-discard'); $page->click('@unsaved-changes-discard'); - waitForUnsavedChangesCondition($page, "!document.querySelector('[data-testid=\"post-composer-dialog\"]')"); + waitForGone($page, '@post-composer-dialog'); $page->back(); $page->script('new Promise((resolve) => setTimeout(resolve, 800))'); @@ -486,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', @@ -517,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); - waitForUnsavedChangesTestId($page, 'channel-settings-rows'); - waitForUnsavedChangesCondition($page, $settledCondition); + waitFor($page, '@channel-settings-rows'); + waitForState($page, $settledSelector, $settledState); $page->click('@composer-close'); - waitForUnsavedChangesCondition($page, "!document.querySelector('[data-testid=\"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 () { @@ -545,18 +536,102 @@ 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); $page = visit(route('app.posts.edit', $post))->resize(1280, 900); - waitForUnsavedChangesTestId($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'))"); - waitForUnsavedChangesCondition($page, "document.querySelector('[data-testid=\"instagram-music-selected-cover\"]')?.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'); + waitForGone($page, '@post-composer-dialog'); + + $page->assertMissing('@unsaved-changes-dialog') + ->assertMissing('@post-composer-dialog') + ->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); + 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') + ->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); + waitFor($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}"); + 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"]'); + const options = { bubbles: true, cancelable: true, pointerType: 'mouse', button: 0 }; + target.dispatchEvent(new PointerEvent('pointerdown', options)); + target.dispatchEvent(new PointerEvent('pointerup', options)); + })() + JS); + $page->assertMissing('@instagram-music-selected-cover'); + waitForState($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\"]')"); + waitForGoneOrShown($page, gone: '@post-composer-dialog', shown: '@unsaved-changes-dialog'); $page->assertMissing('@unsaved-changes-dialog') ->assertMissing('@post-composer-dialog') @@ -569,16 +644,16 @@ function openUnsavedChangesFromList(Post $post, ?string $from = null, string $re $page->click('@instagram-share-to-feed') ->script("document.querySelector('[data-testid=\"sidebar-logo\"]').click()"); - waitForUnsavedChangesTestId($page, 'unsaved-changes-discard'); + waitFor($page, '@unsaved-changes-discard'); $page->click('@unsaved-changes-discard'); - waitForUnsavedChangesCondition($page, "!document.querySelector('[data-testid=\"post-composer-dialog\"]')"); + waitForGone($page, '@post-composer-dialog'); $page->back(); - waitForUnsavedChangesTestId($page, 'instagram-share-to-feed'); + waitFor($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\"]')"); + waitForGone($page, '@post-composer-dialog'); $page->assertMissing('@post-composer-dialog') ->assertMissing('@unsaved-changes-dialog') @@ -591,23 +666,23 @@ 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'"); + waitForState($page, '@instagram-share-to-feed', "element.getAttribute('aria-checked') === 'false'"); $page->back(); - waitForUnsavedChangesTestId($page, 'unsaved-changes-keep'); + waitFor($page, '@unsaved-changes-keep'); $page->click('@unsaved-changes-keep'); - waitForUnsavedChangesCondition($page, "!document.querySelector('[data-testid=\"unsaved-changes-dialog\"]')"); + waitForGone($page, '@unsaved-changes-dialog'); $page->script("document.querySelector('[data-testid=\"sidebar-logo\"]').click()"); - waitForUnsavedChangesTestId($page, 'unsaved-changes-discard'); + waitFor($page, '@unsaved-changes-discard'); $page->click('@unsaved-changes-discard'); - waitForUnsavedChangesCondition($page, "!document.querySelector('[data-testid=\"post-composer-dialog\"]')"); + waitForGone($page, '@post-composer-dialog'); $page->back(); - waitForUnsavedChangesTestId($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(); - waitForUnsavedChangesCondition($page, "!document.querySelector('[data-testid=\"post-composer-dialog\"]')"); + waitForGone($page, '@post-composer-dialog'); $page->assertMissing('@post-composer-dialog') ->assertMissing('@unsaved-changes-dialog') @@ -621,18 +696,18 @@ function openUnsavedChangesFromList(Post $post, ?string $from = null, string $re $listPath = unsavedChangesListPath(); $page->click('@instagram-share-to-feed'); - waitForUnsavedChangesCondition($page, "document.querySelector('[data-testid=\"instagram-share-to-feed\"]')?.getAttribute('aria-checked') === 'false'"); + waitForState($page, '@instagram-share-to-feed', "element.getAttribute('aria-checked') === 'false'"); $page->back(); - waitForUnsavedChangesTestId($page, 'unsaved-changes-keep'); + waitFor($page, '@unsaved-changes-keep'); $page->click('@unsaved-changes-keep'); - waitForUnsavedChangesCondition($page, "!document.querySelector('[data-testid=\"unsaved-changes-dialog\"]')"); + waitForGone($page, '@unsaved-changes-dialog'); $page->click('@composer-save-draft'); - waitForUnsavedChangesCondition($page, "!document.querySelector('[data-testid=\"post-composer-dialog\"]')"); + waitForGone($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}"); + waitFor($page, "@post-edit-{$post->id}"); $page->assertMissing('@unsaved-changes-dialog') ->assertPathIs($listPath); @@ -644,10 +719,10 @@ function openUnsavedChangesFromList(Post $post, ?string $from = null, string $re $post = seedUnsavedChangesReel(); $page = visit(route('app.posts.edit', $post))->resize(1280, 900); - waitForUnsavedChangesTestId($page, 'instagram-share-to-feed'); + waitFor($page, '@instagram-share-to-feed'); $page->click('@instagram-share-to-feed') ->keys('@instagram-share-to-feed', 'Escape'); - waitForUnsavedChangesTestId($page, 'unsaved-changes-dialog'); + waitFor($page, '@unsaved-changes-dialog'); $page->assertVisible('@unsaved-changes-dialog') ->assertVisible('@post-composer-dialog') @@ -659,14 +734,14 @@ function openUnsavedChangesFromList(Post $post, ?string $from = null, string $re $account = $post->socialAccount; $page = visit(route('app.posts.index'))->resize(1280, 900); - waitForUnsavedChangesTestId($page, 'posts-new-post'); + waitFor($page, '@posts-new-post'); $page->click('@posts-new-post'); - waitForUnsavedChangesTestId($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'); - waitForUnsavedChangesCondition($page, "!document.querySelector('[data-testid=\"post-composer-dialog\"]')"); + waitForGone($page, '@post-composer-dialog'); $page->assertMissing('@unsaved-changes-dialog') ->assertMissing('@post-composer-dialog') @@ -692,11 +767,11 @@ function openUnsavedChangesFromList(Post $post, ?string $from = null, string $re $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')"); + 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'); - waitForUnsavedChangesCondition($page, "!document.querySelector('[data-testid=\"post-composer-dialog\"]')"); + waitForGone($page, '@post-composer-dialog'); $page->assertNoJavaScriptErrors(); expect($post->fresh()->meta)->toHaveKey('channel_id', '999') @@ -708,15 +783,15 @@ 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'"); + waitForState($page, '@instagram-share-to-feed', "element.getAttribute('aria-checked') === 'false'"); $page->back(); - waitForUnsavedChangesTestId($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'); - waitForUnsavedChangesCondition($page, "!document.querySelector('[data-testid=\"unsaved-changes-dialog\"]')"); + waitForGone($page, '@unsaved-changes-dialog'); $page->assertVisible('@post-composer-dialog') ->assertAttribute('@instagram-share-to-feed', 'aria-checked', 'false') @@ -727,13 +802,13 @@ function openUnsavedChangesFromList(Post $post, ?string $from = null, string $re $post = seedUnsavedChangesReel(); $page = visit(route('app.posts.edit', $post))->resize(1280, 900); - waitForUnsavedChangesTestId($page, 'instagram-share-to-feed'); + waitFor($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'"); + waitForState($page, '@instagram-share-to-feed', "element.getAttribute('aria-checked') === 'false'"); $page->back(); - waitForUnsavedChangesTestId($page, 'unsaved-changes-discard'); + waitFor($page, '@unsaved-changes-discard'); $page->click('@unsaved-changes-discard'); - waitForUnsavedChangesCondition($page, "!document.querySelector('[data-testid=\"post-composer-dialog\"]')"); + waitForGone($page, '@post-composer-dialog'); $page->assertVisible('@sidebar-logo') ->assertMissing('@post-composer-dialog') @@ -747,7 +822,7 @@ function openUnsavedChangesFromList(Post $post, ?string $from = null, string $re $post = seedUnsavedChangesReel(); $page = visit(route('app.posts.edit', $post))->resize(1280, 900); - waitForUnsavedChangesTestId($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') @@ -772,7 +847,7 @@ function openUnsavedChangesFromList(Post $post, ?string $from = null, string $re $post = seedUnsavedChangesReel(); $page = visit(route('app.posts.edit', $post))->resize(1280, 900); - waitForUnsavedChangesTestId($page, 'instagram-share-to-feed'); + waitFor($page, '@instagram-share-to-feed'); $page->click('@instagram-share-to-feed'); $page->script(<<<'JS' (() => { @@ -783,7 +858,7 @@ function openUnsavedChangesFromList(Post $post, ?string $from = null, string $re outside.dispatchEvent(new MouseEvent('click', options)); })() JS); - waitForUnsavedChangesTestId($page, 'unsaved-changes-dialog'); + waitFor($page, '@unsaved-changes-dialog'); $page->assertVisible('@unsaved-changes-dialog') ->assertVisible('@post-composer-dialog') @@ -795,28 +870,28 @@ 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'"); + 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'] })"); - waitForUnsavedChangesCondition($page, "document.querySelector('[data-testid=\"post-edit-{$arrived->id}\"]')"); + waitFor($page, "@post-edit-{$arrived->id}"); foreach (range(1, 2) as $attempt) { $page->back(); - waitForUnsavedChangesTestId($page, 'unsaved-changes-keep'); + waitFor($page, '@unsaved-changes-keep'); $page->click('@unsaved-changes-keep'); - waitForUnsavedChangesCondition($page, "!document.querySelector('[data-testid=\"unsaved-changes-dialog\"]')"); + waitForGone($page, '@unsaved-changes-dialog'); } $page->script("document.querySelector('[data-testid=\"sidebar-logo\"]').click()"); - waitForUnsavedChangesTestId($page, 'unsaved-changes-discard'); + waitFor($page, '@unsaved-changes-discard'); $page->click('@unsaved-changes-discard'); - waitForUnsavedChangesCondition($page, "!document.querySelector('[data-testid=\"post-composer-dialog\"]')"); + waitForGone($page, '@post-composer-dialog'); $page->back(); - waitForUnsavedChangesTestId($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(); @@ -827,10 +902,10 @@ function openUnsavedChangesFromList(Post $post, ?string $from = null, string $re $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'"); + waitForState($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\"]')"); + waitForGone($page, '@post-composer-dialog'); $page->script('new Promise((resolve) => setTimeout(resolve, 800))'); $page->assertMissing('@post-composer-dialog') @@ -840,7 +915,7 @@ function openUnsavedChangesFromList(Post $post, ?string $from = null, string $re ->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(); @@ -852,25 +927,25 @@ function openUnsavedChangesFromList(Post $post, ?string $from = null, string $re $page->type('@instagram-collaborator-input', 'bad-name!') ->click('@instagram-collaborator-add'); - waitForUnsavedChangesTestId($page, 'instagram-collaborators-notice'); + waitFor($page, '@instagram-collaborators-notice'); $page->click('@instagram-collaborators-notice-dismiss'); - waitForUnsavedChangesCondition($page, "!document.querySelector('[data-testid=\"instagram-collaborators-notice\"]')"); + waitForGone($page, '@instagram-collaborators-notice'); $page->click('@composer-save-draft'); - waitForUnsavedChangesCondition($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'); - waitForUnsavedChangesTestId($page, 'unsaved-changes-keep'); + waitFor($page, '@unsaved-changes-keep'); $page->click('@unsaved-changes-keep'); - waitForUnsavedChangesCondition($page, "!document.querySelector('[data-testid=\"unsaved-changes-dialog\"]')"); + waitForGone($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'); + waitFor($page, '@instagram-collaborator-alice'); $page->click('@composer-save-draft'); - waitForUnsavedChangesCondition($page, "!document.querySelector('[data-testid=\"post-composer-dialog\"]')"); + waitForGone($page, '@post-composer-dialog'); $page->back(); $page->script('new Promise((resolve) => setTimeout(resolve, 800))'); @@ -886,22 +961,23 @@ function openUnsavedChangesFromList(Post $post, ?string $from = null, string $re $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'"); + waitForState($page, '@instagram-share-to-feed', "element.getAttribute('aria-checked') === 'false'"); $page->back(); - waitForUnsavedChangesTestId($page, 'unsaved-changes-keep'); + waitFor($page, '@unsaved-changes-keep'); $page->click('@unsaved-changes-keep'); - waitForUnsavedChangesCondition($page, "!document.querySelector('[data-testid=\"unsaved-changes-dialog\"]')"); + waitForGone($page, '@unsaved-changes-dialog'); $page->click('@instagram-share-to-feed'); - waitForUnsavedChangesCondition($page, "document.querySelector('[data-testid=\"instagram-share-to-feed\"]')?.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'); - waitForUnsavedChangesCondition($page, "!document.querySelector('[data-testid=\"post-composer-dialog\"]') && !location.search.includes('edit=')"); + waitForGone($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(); @@ -913,10 +989,11 @@ function openUnsavedChangesFromList(Post $post, ?string $from = null, string $re $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'"); + 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'); - waitForUnsavedChangesCondition($page, "!document.querySelector('[data-testid=\"post-composer-dialog\"]') && !location.search.includes('edit=')"); + waitForGone($page, '@post-composer-dialog'); + waitForCondition($page, "!location.search.includes('edit=')"); $page->back(); $page->script('new Promise((resolve) => setTimeout(resolve, 800))'); @@ -958,7 +1035,7 @@ function openUnsavedChangesFromList(Post $post, ?string $from = null, string $re $this->actingAs($user); $page = visit(route('app.posts.index', ['tab' => 'drafts']))->resize(1280, 900); - waitForUnsavedChangesTestId($page, "post-edit-{$post->id}"); + waitFor($page, "@post-edit-{$post->id}"); $page->script(<<<'JS' (() => { const open = XMLHttpRequest.prototype.open; @@ -977,7 +1054,7 @@ function openUnsavedChangesFromList(Post $post, ?string $from = null, string $re })() JS); $page->click("@post-edit-{$post->id}"); - waitForUnsavedChangesTestId($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"]'); @@ -987,9 +1064,9 @@ function openUnsavedChangesFromList(Post $post, ?string $from = null, string $re })() 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"); + waitForState($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\"]')"); + waitForGoneOrShown($page, gone: '@post-composer-dialog', shown: '@unsaved-changes-dialog'); $page->assertMissing('@unsaved-changes-dialog') ->assertMissing('@post-composer-dialog') @@ -1037,13 +1114,13 @@ function openUnsavedChangesFromList(Post $post, ?string $from = null, string $re input.dispatchEvent(new Event('change', { bubbles: true })); })() JS); - waitForUnsavedChangesTestId($page, "composer-{$account->id}-upload-item"); + waitFor($page, "@composer-{$account->id}-upload-item"); $page->click('@composer-close'); - waitForUnsavedChangesTestId($page, 'unsaved-changes-dialog'); + waitFor($page, '@unsaved-changes-dialog'); $page->assertVisible('@unsaved-changes-dialog') ->click('@unsaved-changes-discard'); - waitForUnsavedChangesCondition($page, "!document.querySelector('[data-testid=\"post-composer-dialog\"]')"); + waitForGone($page, '@post-composer-dialog'); $page->assertMissing('@post-composer-dialog') ->assertNoJavaScriptErrors(); @@ -1066,9 +1143,9 @@ function openUnsavedChangesFromList(Post $post, ?string $from = null, string $re $page->click("@composer-caption-{$account->id}") ->type("@composer-caption-{$account->id}", ' later') ->click('@composer-schedule-trigger'); - waitForUnsavedChangesTestId($page, 'composer-queue-manage-slots'); + waitFor($page, '@composer-queue-manage-slots'); $page->click('@composer-queue-manage-slots'); - waitForUnsavedChangesTestId($page, 'unsaved-changes-dialog'); + waitFor($page, '@unsaved-changes-dialog'); $page->assertVisible('@unsaved-changes-dialog') ->assertVisible('@post-composer-dialog') 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'); +}); diff --git a/tests/Pest.php b/tests/Pest.php index 7c3ac67540..c29c121afe 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -562,3 +562,93 @@ 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 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 jsSelector(string $selector): string +{ + return json_encode(preg_replace('/@([\w-]+)/', '[data-testid="$1"]', $selector)); +} + +/** + * Wait until an element matching the selector exists and the expression, + * written about `element`, is true: "element.getAttribute('aria-checked') === 'true'". + */ +function waitForState(mixed $page, string $selector, string $expression): void +{ + $selector = jsSelector($selector); + + waitForCondition($page, "((element) => element && ({$expression}))(document.querySelector({$selector}))"); +} + +/** + * 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 matches the selector. + */ +function waitForGone(mixed $page, string $selector): void +{ + $selector = jsSelector($selector); + + waitForCondition($page, "!document.querySelector({$selector})"); +} + +/** + * Wait until any element matching the selector contains the text. + */ +function waitForText(mixed $page, string $selector, string $text): void +{ + $selector = jsSelector($selector); + $text = json_encode($text); + + waitForCondition($page, "[...document.querySelectorAll({$selector})].some((element) => element.textContent.includes({$text}))"); +} + +/** + * Wait until one element is gone or another is shown, whichever happens + * first, so the assertion after it fails at once on the other outcome + * instead of waiting out the timeout. + */ +function waitForGoneOrShown(mixed $page, string $gone, string $shown): void +{ + $gone = jsSelector($gone); + $shown = jsSelector($shown); + + waitForCondition($page, "!document.querySelector({$gone}) || document.querySelector({$shown})"); +} + +/** + * Wait until the page is at this path. + */ +function waitForPath(mixed $page, string $path): void +{ + $path = json_encode($path); + + waitForCondition($page, "location.pathname === {$path}"); +}