diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php
index 2cd8c6a..ba2eaa0 100644
--- a/app/Providers/AppServiceProvider.php
+++ b/app/Providers/AppServiceProvider.php
@@ -2,8 +2,10 @@
namespace App\Providers;
+use App\Support\Publishing\LinkedInPublisher;
use App\Support\Publishing\PublisherManager;
use App\Support\Publishing\RedditPublisher;
+use App\Support\Publishing\TwitterPublisher;
use App\Support\Publishing\WordPressPublisher;
use Illuminate\Support\ServiceProvider;
@@ -17,6 +19,8 @@ class AppServiceProvider extends ServiceProvider
$this->app->singleton(PublisherManager::class, fn ($app) => new PublisherManager([
$app->make(WordPressPublisher::class),
$app->make(RedditPublisher::class),
+ $app->make(TwitterPublisher::class),
+ $app->make(LinkedInPublisher::class),
]));
}
diff --git a/app/Support/Publishing/LinkedInPublisher.php b/app/Support/Publishing/LinkedInPublisher.php
new file mode 100644
index 0000000..425536c
--- /dev/null
+++ b/app/Support/Publishing/LinkedInPublisher.php
@@ -0,0 +1,54 @@
+type === 'linkedin';
+ }
+
+ public function publish(Post $post): PublishResult
+ {
+ if (! $post->body) {
+ return PublishResult::rejected("Post {$post->id} has no body, required for a LinkedIn post.");
+ }
+
+ $account = $post->socialAccount;
+
+ if (! $account || $account->network !== 'linkedin') {
+ return PublishResult::rejected("Post {$post->id} has no linked LinkedIn social account.");
+ }
+
+ // LinkedIn uses email and password
+ $result = Process::timeout(120)->run([
+ base_path('sidecars/linkedin-post/run.sh'),
+ "--email={$account->email}",
+ "--password={$account->password}",
+ "--post=" . escapeshellarg($post->body),
+ ]);
+
+ $data = json_decode(trim($result->output()), true);
+
+ if (! is_array($data) || ! array_key_exists('success', $data)) {
+ Log::error('linkedin sidecar returned unparseable output', [
+ 'post_id' => $post->id,
+ 'output' => $result->output(),
+ 'stderr' => $result->errorOutput(),
+ ]);
+
+ return PublishResult::failed('LinkedIn sidecar returned unparseable output — check logs.');
+ }
+
+ if (! $data['success']) {
+ return PublishResult::failed($data['error'] ?? 'unknown LinkedIn sidecar failure');
+ }
+
+ return PublishResult::success($data['url']);
+ }
+}
diff --git a/app/Support/Publishing/TwitterPublisher.php b/app/Support/Publishing/TwitterPublisher.php
new file mode 100644
index 0000000..ef6df12
--- /dev/null
+++ b/app/Support/Publishing/TwitterPublisher.php
@@ -0,0 +1,54 @@
+type === 'twitter';
+ }
+
+ public function publish(Post $post): PublishResult
+ {
+ if (! $post->body) {
+ return PublishResult::rejected("Post {$post->id} has no body, required for a Twitter post.");
+ }
+
+ $account = $post->socialAccount;
+
+ if (! $account || $account->network !== 'twitter') {
+ return PublishResult::rejected("Post {$post->id} has no linked Twitter social account.");
+ }
+
+ // Twitter uses username (which may be email or username) and password
+ $result = Process::timeout(120)->run([
+ base_path('sidecars/twitter-post/run.sh'),
+ "--username={$account->username}",
+ "--password={$account->password}",
+ "--tweet=" . escapeshellarg($post->body),
+ ]);
+
+ $data = json_decode(trim($result->output()), true);
+
+ if (! is_array($data) || ! array_key_exists('success', $data)) {
+ Log::error('twitter sidecar returned unparseable output', [
+ 'post_id' => $post->id,
+ 'output' => $result->output(),
+ 'stderr' => $result->errorOutput(),
+ ]);
+
+ return PublishResult::failed('Twitter sidecar returned unparseable output — check logs.');
+ }
+
+ if (! $data['success']) {
+ return PublishResult::failed($data['error'] ?? 'unknown Twitter sidecar failure');
+ }
+
+ return PublishResult::success($data['url']);
+ }
+}
diff --git a/resources/views/livewire/posts/index.blade.php b/resources/views/livewire/posts/index.blade.php
index 8015530..713f708 100644
--- a/resources/views/livewire/posts/index.blade.php
+++ b/resources/views/livewire/posts/index.blade.php
@@ -47,7 +47,7 @@
{{ $post->scheduled_at?->format('Y-m-d H:i') ?? '—' }} |
{{ $post->socialAccount?->username ?? '—' }} |
- @if (in_array($post->type, ['blog', 'reddit']) && in_array($post->status, ['draft', 'scheduled', 'failed']))
+ @if (in_array($post->type, ['blog', 'reddit', 'twitter', 'linkedin']) && in_array($post->status, ['draft', 'scheduled', 'failed']))
@endif
diff --git a/sidecars/linkedin-post/.gitignore b/sidecars/linkedin-post/.gitignore
new file mode 100644
index 0000000..3d6c1b1
--- /dev/null
+++ b/sidecars/linkedin-post/.gitignore
@@ -0,0 +1,3 @@
+/node_modules
+/test-results
+/.env
diff --git a/sidecars/linkedin-post/README.md b/sidecars/linkedin-post/README.md
new file mode 100644
index 0000000..105323f
--- /dev/null
+++ b/sidecars/linkedin-post/README.md
@@ -0,0 +1,28 @@
+# LinkedIn Post Sidecar
+
+A Playwright acceptance test that logs into LinkedIn with supplied credentials and creates a post.
+
+## One-time setup
+
+ cd sidecars/linkedin-post
+ npm install
+ npx playwright install chromium
+
+## Usage
+
+ ./run.sh --email=YOUR_EMAIL --password=YOUR_PASSWORD \
+ --post="Your post content here" [--headed]
+
+Prints a single JSON line to stdout and exits 0 on success, exits 1 on failure:
+
+ {"success": true, "url": "https://www.linkedin.com/posts/..."}
+ {"success": false, "error": "Login failed: Invalid credentials"}
+
+## Notes
+
+- On a failed run, Playwright retains a screenshot and trace under `test-results/` for debugging.
+ These are gitignored and not committed, but they persist on disk.
+- LinkedIn's UI changes periodically. If posting fails, check the selectors in `tests/linkedin-post.spec.js`
+ and update them using: `npx playwright codegen https://www.linkedin.com/login`
+- LinkedIn may show CAPTCHAs or 2FA prompts which will cause the automation to fail.
+- For company pages, you may need to switch to the page after login before posting.
diff --git a/sidecars/linkedin-post/package.json b/sidecars/linkedin-post/package.json
new file mode 100644
index 0000000..66e4c8f
--- /dev/null
+++ b/sidecars/linkedin-post/package.json
@@ -0,0 +1,12 @@
+{
+ "name": "linkedin-post-sidecar",
+ "private": true,
+ "version": "1.0.0",
+ "description": "Playwright acceptance test that logs into LinkedIn and creates a post, invoked via run.sh",
+ "scripts": {
+ "test": "playwright test"
+ },
+ "devDependencies": {
+ "@playwright/test": "^1.61.1"
+ }
+}
diff --git a/sidecars/linkedin-post/playwright.config.js b/sidecars/linkedin-post/playwright.config.js
new file mode 100644
index 0000000..cbabf37
--- /dev/null
+++ b/sidecars/linkedin-post/playwright.config.js
@@ -0,0 +1,14 @@
+/** @type {import('@playwright/test').PlaywrightTestConfig} */
+const config = {
+ testDir: './tests',
+ fullyParallel: false,
+ workers: 1,
+ reporter: 'json',
+ use: {
+ trace: 'retain-on-failure',
+ screenshot: 'only-on-failure',
+ video: 'retain-on-failure',
+ },
+};
+
+export default config;
diff --git a/sidecars/linkedin-post/run.sh b/sidecars/linkedin-post/run.sh
new file mode 100755
index 0000000..e267745
--- /dev/null
+++ b/sidecars/linkedin-post/run.sh
@@ -0,0 +1,56 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+cd "$SCRIPT_DIR"
+
+EMAIL=""
+PASSWORD=""
+POST_TEXT=""
+HEADLESS_FLAG="true"
+
+for arg in "$@"; do
+ case "$arg" in
+ --email=*) EMAIL="${arg#*=}" ;;
+ --password=*) PASSWORD="${arg#*=}" ;;
+ --post=*) POST_TEXT="${arg#*=}" ;;
+ --headed) HEADLESS_FLAG="false" ;;
+ *)
+ UNKNOWN_FLAG="${arg%%=*}"
+ echo "{\"success\": false, \"error\": \"Unknown argument: $UNKNOWN_FLAG\"}"
+ exit 1
+ ;;
+ esac
+done
+
+MISSING=""
+[ -z "$EMAIL" ] && MISSING="$MISSING --email"
+[ -z "$PASSWORD" ] && MISSING="$MISSING --password"
+[ -z "$POST_TEXT" ] && MISSING="$MISSING --post"
+
+if [ -n "$MISSING" ]; then
+ echo "{\"success\": false, \"error\": \"Missing required argument(s):$MISSING\"}"
+ exit 1
+fi
+
+export LINKEDIN_EMAIL="$EMAIL"
+export LINKEDIN_PASSWORD="$PASSWORD"
+export POST_CONTENT="$POST_TEXT"
+export HEADLESS="$HEADLESS_FLAG"
+
+set +e
+npx playwright test tests/linkedin-post.spec.js
+PLAYWRIGHT_EXIT_CODE=$?
+set -e
+
+RESULT_FILE="test-results/result.json"
+
+if [ -f "$RESULT_FILE" ]; then
+ cat "$RESULT_FILE"
+ echo
+else
+ echo "{\"success\": false, \"error\": \"Test did not produce a result file\"}"
+ exit 1
+fi
+
+exit "$PLAYWRIGHT_EXIT_CODE"
diff --git a/sidecars/linkedin-post/tests/linkedin-post.spec.js b/sidecars/linkedin-post/tests/linkedin-post.spec.js
new file mode 100644
index 0000000..1a97815
--- /dev/null
+++ b/sidecars/linkedin-post/tests/linkedin-post.spec.js
@@ -0,0 +1,137 @@
+// @ts-check
+const { test, expect } = require('@playwright/test');
+const fs = require('fs');
+const path = require('path');
+
+const RESULT_PATH = path.join(__dirname, '..', 'test-results', 'result.json');
+
+function writeResult(result) {
+ fs.mkdirSync(path.dirname(RESULT_PATH), { recursive: true });
+ fs.writeFileSync(RESULT_PATH, JSON.stringify(result));
+}
+
+test.describe('linkedin post', () => {
+ let outcome = { success: false, error: 'Test did not complete' };
+ let outcomeFinalized = false;
+
+ test.afterEach(async () => {
+ writeResult(outcome);
+ });
+
+ test('logs into LinkedIn and creates a post', async ({ page }) => {
+ const email = process.env.LINKEDIN_EMAIL;
+ const password = process.env.LINKEDIN_PASSWORD;
+ const postContent = process.env.POST_CONTENT;
+
+ for (const [name, value] of Object.entries({
+ LINKEDIN_EMAIL: email,
+ LINKEDIN_PASSWORD: password,
+ POST_CONTENT: postContent,
+ })) {
+ if (!value) {
+ outcome = { success: false, error: `Missing required environment variable: ${name}` };
+ outcomeFinalized = true;
+ throw new Error(outcome.error);
+ }
+ }
+
+ try {
+ await page.goto('https://www.linkedin.com/login');
+
+ // Wait for page to load
+ await page.waitForLoadState('networkidle');
+
+ // Check for login form
+ const emailField = page.getByLabel(/email|phone/i).first();
+ if (!await emailField.isVisible({ timeout: 10000 }).catch(() => false)) {
+ outcome = {
+ success: false,
+ error: 'LinkedIn login form not found — page structure may have changed',
+ };
+ outcomeFinalized = true;
+ throw new Error(outcome.error);
+ }
+
+ // Fill email
+ await emailField.fill(email);
+
+ // Fill password
+ const passwordField = page.getByLabel(/password/i).first();
+ if (await passwordField.isVisible({ timeout: 10000 }).catch(() => false)) {
+ await passwordField.fill(password);
+ }
+
+ // Click sign in button
+ const signInButton = page.getByRole('button', { name: /sign in|signin|log in/i, exact: false }).first();
+ await signInButton.click();
+
+ // Wait for login to complete - check for feed or start post button
+ try {
+ await page.waitForURL('https://www.linkedin.com/feed/', { timeout: 20000 });
+ } catch {
+ // Check if we're logged in by looking for start post button
+ const startPostButton = page.getByRole('button', { name: /start a post|post/i, exact: false }).first();
+ if (!await startPostButton.isVisible({ timeout: 10000 }).catch(() => false)) {
+ const errorText = await page.textContent('body').catch(() => '');
+ outcome = {
+ success: false,
+ error: errorText.toLowerCase().includes('invalid') || errorText.toLowerCase().includes('incorrect')
+ ? 'Invalid LinkedIn credentials'
+ : 'LinkedIn login did not complete — possible CAPTCHA, 2FA, or invalid credentials',
+ };
+ outcomeFinalized = true;
+ throw new Error(outcome.error);
+ }
+ }
+
+ // Click "Start a post" button
+ const startPostButton = page.getByRole('button', { name: /start a post|post/i, exact: false }).first();
+ await startPostButton.click();
+
+ // Wait for post composer
+ const postTextarea = page.getByRole('textbox', { name: /what do you want to talk about/i, exact: false }).first();
+ if (!await postTextarea.isVisible({ timeout: 10000 }).catch(() => false)) {
+ // Try alternative selector
+ const altTextarea = page.locator('div[contenteditable="true"]').first();
+ if (await altTextarea.isVisible({ timeout: 5000 }).catch(() => false)) {
+ await altTextarea.click();
+ await page.keyboard.type(postContent);
+ } else {
+ outcome = {
+ success: false,
+ error: 'LinkedIn post composer not found — UI may have changed',
+ };
+ outcomeFinalized = true;
+ throw new Error(outcome.error);
+ }
+ } else {
+ await postTextarea.fill(postContent);
+ }
+
+ // Click post button
+ const postButton = page.getByRole('button', { name: /^post$/i, exact: true }).first();
+ if (await postButton.isVisible({ timeout: 5000 }).catch(() => false)) {
+ await postButton.click();
+ } else {
+ // Try alternative post button selectors
+ const altPostButton = page.getByRole('button', { name: /share|post/i, exact: false }).nth(1);
+ if (await altPostButton.isVisible({ timeout: 5000 }).catch(() => false)) {
+ await altPostButton.click();
+ }
+ }
+
+ // Wait for post to be created
+ await page.waitForTimeout(3000);
+
+ // Get current URL (should be the post URL or feed)
+ outcome = { success: true, url: page.url() };
+ outcomeFinalized = true;
+ expect(outcome.success).toBe(true);
+ } catch (err) {
+ if (!outcomeFinalized) {
+ outcome = { success: false, error: err instanceof Error ? err.message : String(err) };
+ }
+ throw err;
+ }
+ });
+});
diff --git a/sidecars/twitter-post/.gitignore b/sidecars/twitter-post/.gitignore
new file mode 100644
index 0000000..3d6c1b1
--- /dev/null
+++ b/sidecars/twitter-post/.gitignore
@@ -0,0 +1,3 @@
+/node_modules
+/test-results
+/.env
diff --git a/sidecars/twitter-post/README.md b/sidecars/twitter-post/README.md
new file mode 100644
index 0000000..14e58ee
--- /dev/null
+++ b/sidecars/twitter-post/README.md
@@ -0,0 +1,27 @@
+# Twitter/X Post Sidecar
+
+A Playwright acceptance test that logs into Twitter/X with supplied credentials and creates a post.
+
+## One-time setup
+
+ cd sidecars/twitter-post
+ npm install
+ npx playwright install chromium
+
+## Usage
+
+ ./run.sh --username=YOUR_USERNAME --password=YOUR_PASSWORD \
+ --tweet="Your tweet text here" [--headed]
+
+Prints a single JSON line to stdout and exits 0 on success, exits 1 on failure:
+
+ {"success": true, "url": "https://twitter.com/username/status/..."}
+ {"success": false, "error": "Login failed: Invalid credentials"}
+
+## Notes
+
+- On a failed run, Playwright retains a screenshot and trace under `test-results/` for debugging.
+ These are gitignored and not committed, but they persist on disk.
+- Twitter's UI changes frequently. If posting fails, check the selectors in `tests/twitter-post.spec.js`
+ and update them using: `npx playwright codegen https://twitter.com/i/flow/login`
+- Twitter may show CAPTCHAs or 2FA prompts which will cause the automation to fail.
diff --git a/sidecars/twitter-post/package.json b/sidecars/twitter-post/package.json
new file mode 100644
index 0000000..e7a9633
--- /dev/null
+++ b/sidecars/twitter-post/package.json
@@ -0,0 +1,12 @@
+{
+ "name": "twitter-post-sidecar",
+ "private": true,
+ "version": "1.0.0",
+ "description": "Playwright acceptance test that logs into Twitter/X and creates a post, invoked via run.sh",
+ "scripts": {
+ "test": "playwright test"
+ },
+ "devDependencies": {
+ "@playwright/test": "^1.61.1"
+ }
+}
diff --git a/sidecars/twitter-post/playwright.config.js b/sidecars/twitter-post/playwright.config.js
new file mode 100644
index 0000000..cbabf37
--- /dev/null
+++ b/sidecars/twitter-post/playwright.config.js
@@ -0,0 +1,14 @@
+/** @type {import('@playwright/test').PlaywrightTestConfig} */
+const config = {
+ testDir: './tests',
+ fullyParallel: false,
+ workers: 1,
+ reporter: 'json',
+ use: {
+ trace: 'retain-on-failure',
+ screenshot: 'only-on-failure',
+ video: 'retain-on-failure',
+ },
+};
+
+export default config;
diff --git a/sidecars/twitter-post/run.sh b/sidecars/twitter-post/run.sh
new file mode 100755
index 0000000..b70a10e
--- /dev/null
+++ b/sidecars/twitter-post/run.sh
@@ -0,0 +1,56 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+cd "$SCRIPT_DIR"
+
+USERNAME=""
+PASSWORD=""
+TWEET=""
+HEADLESS_FLAG="true"
+
+for arg in "$@"; do
+ case "$arg" in
+ --username=*) USERNAME="${arg#*=}" ;;
+ --password=*) PASSWORD="${arg#*=}" ;;
+ --tweet=*) TWEET="${arg#*=}" ;;
+ --headed) HEADLESS_FLAG="false" ;;
+ *)
+ UNKNOWN_FLAG="${arg%%=*}"
+ echo "{\"success\": false, \"error\": \"Unknown argument: $UNKNOWN_FLAG\"}"
+ exit 1
+ ;;
+ esac
+done
+
+MISSING=""
+[ -z "$USERNAME" ] && MISSING="$MISSING --username"
+[ -z "$PASSWORD" ] && MISSING="$MISSING --password"
+[ -z "$TWEET" ] && MISSING="$MISSING --tweet"
+
+if [ -n "$MISSING" ]; then
+ echo "{\"success\": false, \"error\": \"Missing required argument(s):$MISSING\"}"
+ exit 1
+fi
+
+export TWITTER_USERNAME="$USERNAME"
+export TWITTER_PASSWORD="$PASSWORD"
+export TWEET_TEXT="$TWEET"
+export HEADLESS="$HEADLESS_FLAG"
+
+set +e
+npx playwright test tests/twitter-post.spec.js
+PLAYWRIGHT_EXIT_CODE=$?
+set -e
+
+RESULT_FILE="test-results/result.json"
+
+if [ -f "$RESULT_FILE" ]; then
+ cat "$RESULT_FILE"
+ echo
+else
+ echo "{\"success\": false, \"error\": \"Test did not produce a result file\"}"
+ exit 1
+fi
+
+exit "$PLAYWRIGHT_EXIT_CODE"
diff --git a/sidecars/twitter-post/tests/twitter-post.spec.js b/sidecars/twitter-post/tests/twitter-post.spec.js
new file mode 100644
index 0000000..b6923b4
--- /dev/null
+++ b/sidecars/twitter-post/tests/twitter-post.spec.js
@@ -0,0 +1,129 @@
+// @ts-check
+const { test, expect } = require('@playwright/test');
+const fs = require('fs');
+const path = require('path');
+
+const RESULT_PATH = path.join(__dirname, '..', 'test-results', 'result.json');
+
+function writeResult(result) {
+ fs.mkdirSync(path.dirname(RESULT_PATH), { recursive: true });
+ fs.writeFileSync(RESULT_PATH, JSON.stringify(result));
+}
+
+test.describe('twitter post', () => {
+ let outcome = { success: false, error: 'Test did not complete' };
+ let outcomeFinalized = false;
+
+ test.afterEach(async () => {
+ writeResult(outcome);
+ });
+
+ test('logs into Twitter and creates a post', async ({ page }) => {
+ const username = process.env.TWITTER_USERNAME;
+ const password = process.env.TWITTER_PASSWORD;
+ const tweet = process.env.TWEET_TEXT;
+
+ for (const [name, value] of Object.entries({
+ TWITTER_USERNAME: username,
+ TWITTER_PASSWORD: password,
+ TWEET_TEXT: tweet,
+ })) {
+ if (!value) {
+ outcome = { success: false, error: `Missing required environment variable: ${name}` };
+ outcomeFinalized = true;
+ throw new Error(outcome.error);
+ }
+ }
+
+ try {
+ await page.goto('https://twitter.com/i/flow/login');
+
+ // Wait for page to load
+ await page.waitForLoadState('networkidle');
+
+ // Check for login form
+ const usernameField = page.getByLabel(/username|phone|email/i).first();
+ if (!await usernameField.isVisible({ timeout: 10000 }).catch(() => false)) {
+ outcome = {
+ success: false,
+ error: 'Twitter login form not found — page structure may have changed',
+ };
+ outcomeFinalized = true;
+ throw new Error(outcome.error);
+ }
+
+ // Fill username and proceed
+ await usernameField.fill(username);
+ await usernameField.press('Enter');
+
+ // Wait for password field or next step
+ await page.waitForTimeout(2000);
+
+ const passwordField = page.getByLabel(/password/i).first();
+ if (await passwordField.isVisible({ timeout: 10000 }).catch(() => false)) {
+ await passwordField.fill(password);
+ await passwordField.press('Enter');
+ }
+
+ // Wait for login to complete
+ try {
+ await page.waitForURL('https://twitter.com/home', { timeout: 20000 });
+ } catch {
+ // Check if we're logged in by looking for tweet button
+ const tweetButton = page.getByRole('button', { name: /tweet|post/i }).first();
+ if (!await tweetButton.isVisible({ timeout: 5000 }).catch(() => false)) {
+ const errorText = await page.textContent('body').catch(() => '');
+ outcome = {
+ success: false,
+ error: errorText.includes('username') || errorText.includes('password')
+ ? 'Invalid Twitter credentials'
+ : 'Twitter login did not complete — possible CAPTCHA, 2FA, or invalid credentials',
+ };
+ outcomeFinalized = true;
+ throw new Error(outcome.error);
+ }
+ }
+
+ // Click the tweet button
+ const tweetButton = page.getByRole('button', { name: /tweet|post/i }).first();
+ await tweetButton.click();
+
+ // Wait for tweet composer
+ const tweetTextarea = page.getByRole('textbox', { name: /what is happening/i }).first();
+ if (!await tweetTextarea.isVisible({ timeout: 10000 }).catch(() => false)) {
+ outcome = {
+ success: false,
+ error: 'Tweet composer not found — Twitter UI may have changed',
+ };
+ outcomeFinalized = true;
+ throw new Error(outcome.error);
+ }
+
+ // Fill tweet text
+ await tweetTextarea.fill(tweet);
+
+ // Click tweet/post button
+ const postButton = page.getByRole('button', { name: /tweet|post/i }).nth(1);
+ await expect(postButton).toBeEnabled({ timeout: 10000 });
+ await postButton.click();
+
+ // Wait for tweet to be posted
+ try {
+ await page.waitForURL(/(status|post)\//, { timeout: 20000 });
+ } catch {
+ // Check if tweet was posted by looking for success indicators
+ await page.waitForTimeout(3000);
+ }
+
+ // Get current URL (should be the tweet URL)
+ outcome = { success: true, url: page.url() };
+ outcomeFinalized = true;
+ expect(outcome.success).toBe(true);
+ } catch (err) {
+ if (!outcomeFinalized) {
+ outcome = { success: false, error: err instanceof Error ? err.message : String(err) };
+ }
+ throw err;
+ }
+ });
+});
|