frigatebird 0.3.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,22 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.1 - 2026-02-09
4
+
5
+ ### Added
6
+ - Premium-feature live e2e opt-in flag: `--enable-premium-features-e2e`.
7
+ - Live e2e support for dedicated premium account cookies via `--article-cookie-source`.
8
+ - Coverage thresholds in `vitest.config.ts` to enforce release quality gates.
9
+
10
+ ### Changed
11
+ - Live mutation CI now accepts `workflow_dispatch` input `list_name` instead of a repository variable.
12
+ - Premium feature mutation coverage (article publish path) is disabled by default and only runs when explicitly enabled.
13
+ - Article publish automation was updated for current X compose flows (`/compose/articles`, `Write`, and modern composer/title selectors).
14
+ - Live e2e naming now reflects premium-feature scope instead of article-specific semantics.
15
+
16
+ ### Tests
17
+ - Added/updated Playwright client tests for modern article composer fallback behavior.
18
+ - Verified full release gate (`lint`, `build`, `coverage`) and fixture e2e pass on 0.3.1 candidate.
19
+
3
20
  ## 0.3.0 - 2026-02-09
4
21
 
5
22
  ### Added
@@ -12,7 +29,7 @@
12
29
  - Mutation `retweet` flow now uses bounded resilient click handling and explicit completion checks.
13
30
  - Read-only fixture e2e was optimized to run significantly faster while preserving command coverage.
14
31
  - CI and release verification workflows now include fixture e2e smoke runs.
15
- - Live mutation e2e now requires `FRIGATEBIRD_LIVE_LIST_NAME` and always validates list `add/remove/batch` paths.
32
+ - Live mutation e2e now requires a `--list-name` argument and always validates list `add/remove/batch` paths.
16
33
 
17
34
  ### Tests
18
35
  - Expanded unit coverage for session identity parsing and retweet mutation branches.
package/README.md CHANGED
@@ -1,6 +1,9 @@
1
1
  # frigatebird 🐦 — resilient X CLI for posting, articles, replies, reading, and list automation
2
2
 
3
- ![Frigatebird logo](images/frigatebird_logo.jpeg)
3
+ <div style="text-align: center;">
4
+ <img src="images/frigatebird_logo.jpg" alt="Frigatebird logo" />
5
+ </div>
6
+
4
7
 
5
8
  `frigatebird` is a Playwright-first X CLI that preserves the familiar `bird` command-line experience while running on browser automation instead of private GraphQL internals.
6
9
 
@@ -198,21 +201,24 @@ npm run lint
198
201
  npm run test:run # unit/integration
199
202
  npm run test:coverage # unit/integration + coverage
200
203
  npm run test:e2e # e2e only
201
- npm run test:e2e:live # opt-in live mutation e2e (requires env vars below)
204
+ npm run test:e2e:live -- --list-name testlist001 # opt-in live mutation e2e (article mutation disabled by default)
202
205
  npm run smoke:pack-install
203
206
  ```
204
207
 
205
- Live mutation e2e env requirements:
208
+ Live mutation e2e requirements:
209
+ - required argument: `--list-name <your-list-name>`
206
210
  - `FRIGATEBIRD_AUTH_TOKEN`
207
211
  - `FRIGATEBIRD_CT0`
208
- - `FRIGATEBIRD_LIVE_LIST_NAME`
209
- - optional: `FRIGATEBIRD_LIVE_COOKIE_SOURCE`, `FRIGATEBIRD_LIVE_EXPECTED_HANDLE_PREFIX`, `FRIGATEBIRD_LIVE_TARGET_HANDLE`
212
+ - optional args: `--cookie-source <source>`
213
+ - optional env: `FRIGATEBIRD_LIVE_COOKIE_SOURCE`, `FRIGATEBIRD_LIVE_EXPECTED_HANDLE_PREFIX`, `FRIGATEBIRD_LIVE_TARGET_HANDLE`
214
+ - article mutation is disabled by default; enable manually with:
215
+ - `--enable-premium-features-e2e --article-cookie-source chrome --article-expected-handle-prefix Oceanswave`
210
216
 
211
217
  ## Release
212
218
 
213
219
  - CI: `.github/workflows/ci.yml`
214
220
  - npm publish: `.github/workflows/release.yml` (triggered by GitHub Release `published`, trusted publishing/OIDC)
215
- - live mutation CI: `.github/workflows/live-e2e.yml` (manual trigger; validates `FRIGATEBIRD_LIVE_LIST_NAME` + auth secrets before running)
221
+ - live mutation CI: `.github/workflows/live-e2e.yml` (manual trigger; accepts `list_name` workflow input + validates auth secrets before running)
216
222
  - release checklist: `RELEASE.md`
217
223
 
218
224
  ## Notes
@@ -319,6 +319,26 @@ export class PlaywrightXClient {
319
319
  throw new Error("Composer text area not found. X may have changed selectors.");
320
320
  }
321
321
  }
322
+ async recoverComposerFromHome(page, timeoutMs = 12000) {
323
+ await page.goto(this.absolute("/home"), {
324
+ waitUntil: "domcontentloaded",
325
+ });
326
+ await this.dismissBlockingLayers(page);
327
+ const composeSelectors = [
328
+ '[data-testid="SideNav_NewTweet_Button"]',
329
+ '[data-testid="FloatingActionButton_Tweet"]',
330
+ 'a[href="/compose/post"]',
331
+ '[role="button"][aria-label*="Post"]',
332
+ ];
333
+ await this.clickFirstVisible(page, composeSelectors, 4000).catch(() => false);
334
+ try {
335
+ await this.waitForComposer(page, timeoutMs);
336
+ return true;
337
+ }
338
+ catch {
339
+ return false;
340
+ }
341
+ }
322
342
  async fillComposer(page, text) {
323
343
  const composerSelectors = [
324
344
  '[data-testid="tweetTextarea_0"]',
@@ -696,7 +716,17 @@ export class PlaywrightXClient {
696
716
  await page.goto(this.absolute("/compose/post"), {
697
717
  waitUntil: "domcontentloaded",
698
718
  });
699
- await this.waitForComposer(page, 12000);
719
+ await this.dismissBlockingLayers(page);
720
+ let composerReady = true;
721
+ try {
722
+ await this.waitForComposer(page, 12000);
723
+ }
724
+ catch {
725
+ composerReady = await this.recoverComposerFromHome(page, 12000);
726
+ }
727
+ if (!composerReady) {
728
+ throw new Error("Composer text area not found. X may have changed selectors.");
729
+ }
700
730
  await this.fillComposer(page, text);
701
731
  await this.attachMedia(page, mediaSpecs);
702
732
  await this.submitComposer(page, "tweet");
@@ -717,6 +747,7 @@ export class PlaywrightXClient {
717
747
  return this.withPage(async (page) => {
718
748
  await this.ensureAuth(page);
719
749
  const composePaths = [
750
+ "/compose/articles",
720
751
  "/i/articles/new",
721
752
  "/compose/article",
722
753
  "/write",
@@ -731,19 +762,36 @@ export class PlaywrightXClient {
731
762
  '[role="textbox"][aria-label*="Title"]',
732
763
  'input[placeholder*="Title"]',
733
764
  'textarea[placeholder*="Title"]',
765
+ 'input[placeholder*="title" i]',
766
+ 'textarea[placeholder*="title" i]',
767
+ 'textarea[placeholder*="Add a title"]',
734
768
  '[contenteditable="true"][aria-label*="Title"]',
735
769
  ];
736
770
  const bodySelectors = [
737
771
  '[data-testid="articleBodyInput"]',
738
772
  '[data-testid="article-editor"] [contenteditable="true"]',
773
+ '[data-testid="composer"][role="textbox"]',
774
+ '[data-testid="composer"]',
739
775
  '.ProseMirror[contenteditable="true"]',
740
776
  '[role="textbox"][aria-label*="Write"]',
741
777
  '[contenteditable="true"][aria-label*="Write"]',
742
778
  '[role="textbox"][aria-label*="Body"]',
743
779
  'textarea[placeholder*="Write"]',
744
780
  ];
781
+ const combinedComposerSelectors = [
782
+ '[data-testid="composer"][role="textbox"]',
783
+ '[data-testid="composer"]',
784
+ '[contenteditable="true"][data-testid="composer"]',
785
+ ];
786
+ const writeSelectors = [
787
+ '[data-testid="empty_state_button_text"]:has-text("Write")',
788
+ '[role="button"]:has-text("Write")',
789
+ '[role="link"]:has-text("Write")',
790
+ '[role="button"]:has-text("New article")',
791
+ ];
745
792
  let titleField = null;
746
793
  let bodyField = null;
794
+ let combinedComposer = null;
747
795
  for (const path of composePaths) {
748
796
  await page
749
797
  .goto(this.absolute(path), { waitUntil: "domcontentloaded" })
@@ -754,25 +802,54 @@ export class PlaywrightXClient {
754
802
  '[role="button"]:has-text("Write article")',
755
803
  '[data-testid="articleComposerButton"]',
756
804
  'a[href*="/i/articles/new"]',
805
+ 'a[href*="/compose/articles"]',
806
+ '[role="link"]:has-text("Articles")',
757
807
  ], 1500).catch(() => false);
808
+ await this.clickFirstVisible(page, writeSelectors, 1500).catch(() => false);
758
809
  titleField = await this.firstVisibleLocator(page, titleSelectors, 3500, 120);
759
810
  bodyField = await this.firstVisibleLocator(page, bodySelectors, 3500, 120);
760
- if (titleField && bodyField) {
811
+ combinedComposer = await this.firstVisibleLocator(page, combinedComposerSelectors, 2500, 120);
812
+ if ((titleField && bodyField) || combinedComposer) {
761
813
  break;
762
814
  }
763
815
  }
764
816
  if (!titleField || !bodyField) {
765
- return fail("Could not locate article composer. Articles may be unavailable for this account or X changed selectors.");
817
+ if (combinedComposer) {
818
+ await combinedComposer.click().catch(() => { });
819
+ await combinedComposer.fill(`${cleanTitle}\n\n${cleanBody}`);
820
+ const inserted = await combinedComposer
821
+ .evaluate((node) => {
822
+ const element = node;
823
+ return (element.innerText ||
824
+ element.textContent ||
825
+ element.value ||
826
+ "").trim();
827
+ })
828
+ .catch(() => "");
829
+ if (inserted.length < Math.min(8, cleanTitle.length) ||
830
+ !inserted
831
+ .toLowerCase()
832
+ .includes(cleanBody.slice(0, 8).toLowerCase())) {
833
+ await page.keyboard
834
+ .type(`${cleanTitle}\n\n${cleanBody}`)
835
+ .catch(() => { });
836
+ }
837
+ }
838
+ else {
839
+ return fail("Could not locate article composer. Articles may be unavailable for this account or X changed selectors.");
840
+ }
841
+ }
842
+ else {
843
+ await titleField.click().catch(() => { });
844
+ await titleField.fill(cleanTitle);
845
+ await bodyField.click().catch(() => { });
846
+ await bodyField.fill(cleanBody);
766
847
  }
767
- await titleField.click().catch(() => { });
768
- await titleField.fill(cleanTitle);
769
- await bodyField.click().catch(() => { });
770
- await bodyField.fill(cleanBody);
771
848
  const published = await this.clickFirstVisible(page, [
772
849
  '[data-testid="articlePublishButton"]:not([aria-disabled="true"])',
773
850
  '[data-testid="publishButton"]:not([aria-disabled="true"])',
774
- '[role="button"]:has-text("Publish"):not([aria-disabled="true"])',
775
- '[role="button"]:has-text("Post"):not([aria-disabled="true"])',
851
+ '[role="button"]:has-text("Publish"):not([aria-disabled="true"]):not([disabled])',
852
+ '[role="button"]:has-text("Post"):not([aria-disabled="true"]):not([disabled])',
776
853
  ], 12000).catch(() => false);
777
854
  if (!published) {
778
855
  return fail("Could not locate article publish button.");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "frigatebird",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "description": "Playwright-first X CLI with bird-style parity and resilient web automation",
5
5
  "type": "module",
6
6
  "main": "./dist/cli.js",
@@ -42,7 +42,7 @@
42
42
  "test:run": "vitest run --config vitest.config.ts",
43
43
  "test:coverage": "vitest run --config vitest.config.ts --coverage",
44
44
  "test:e2e": "vitest run --config vitest.e2e.config.ts",
45
- "test:e2e:live": "FRIGATEBIRD_LIVE_E2E=1 vitest run --config vitest.e2e.config.ts tests/e2e/live-mutation-account.e2e.test.ts",
45
+ "test:e2e:live": "tsx scripts/run-live-e2e.ts",
46
46
  "lint": "biome check .",
47
47
  "lint:fix": "biome check --write .",
48
48
  "release:check": "npm run lint && npm run build && npm run test:coverage",