Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion app/Mcp/Tools/SocialAccount/GetInstagramAudioTool.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion app/Mcp/Tools/SocialAccount/SearchInstagramAudioTool.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion app/Support/PostPlatformMetaRules.php
Original file line number Diff line number Diff line change
Expand Up @@ -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).',
Expand Down
19 changes: 17 additions & 2 deletions resources/js/components/posts/editor/InstagramSettings.vue
Original file line number Diff line number Diff line change
Expand Up @@ -222,8 +222,10 @@ const refreshSelectedAudio = async (): Promise<boolean> => {
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,
},
});

Expand All @@ -233,6 +235,19 @@ const refreshSelectedAudio = async (): Promise<boolean> => {
}
};

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);
Expand Down
45 changes: 37 additions & 8 deletions resources/js/composables/composer/useComposerUnsavedChanges.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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<string, any>): Record<string, any> => {
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,
});
};
Expand Down
Loading
Loading