Skip to content

Conversation

@JanCizmar
Copy link
Contributor

@JanCizmar JanCizmar commented Aug 23, 2025

Summary by CodeRabbit

  • New Features

    • Watch mode for pull (--watch) with live re-pulls, debounce, polling and graceful shutdown
  • Improvements

    • ETag / If-None-Match support to skip unchanged exports and in-memory ETag persistence
    • New typed WebSocket client with auth support, auto-reconnect and lifecycle controls
    • Server-version checks and version-compare utility with clearer auth error messages
    • CLI logging newline fix for non-TTY output
  • Chores

    • Added runtime and dev dependencies for WebSocket support
  • Tests

    • New unit and E2E tests covering watch flow, auth handler, version logic, and pull behavior
  • Documentation

    • HACKING.md: option to use an alternative backend for E2E testing

✏️ Tip: You can customize this high-level summary in your review settings.

@coderabbitai
Copy link

coderabbitai bot commented Aug 23, 2025

Walkthrough

Adds websocket-based watch mode for pull with STOMP/SockJS, ETag-aware conditional export, auth-aware handlers, in-memory ETag storage, version utility, CI/type-check updates, runtime websocket deps, and supporting unit and E2E tests and utilities.

Changes

Cohort / File(s) Summary
Runtime Dependencies
package.json
Added @stomp/stompjs, sockjs-client, ws; dev @types/sockjs-client.
Export / API tweaks
src/client/ExportClient.ts, src/client/TolgeeClient.ts
ExportRequest gains optional ifNoneMatch; ExportClient forwards If-None-Match header; added exported LoadableError, removed exitWithError usage.
WebSocket client
src/client/WebsocketClient.ts
New WebsocketClient factory implementing STOMP-over-SockJS with JWT/API-key auth, subscribe/connect/deactivate APIs, reconnection/resubscribe logic, typed channels/events, and subscription tracking.
Pull command & flow
src/commands/pull.ts
Added --watch option; refactored into doPull; fetchZipBlob accepts ifNoneMatch, returns { data, etag, notModified }; integrates ETag persistence and not-modified handling.
Watch orchestration & auth handling
src/utils/pullWatch/watchHandler.ts, src/utils/pullWatch/AuthErrorHandler.ts
New startWatching for WS subscription, debounced pulls, polling fallback, ETag handling, graceful shutdown; AuthErrorHandler inspects auth errors, fetches server version, logs version-aware messages, triggers shutdown.
ETag & version utilities
src/utils/eTagStorage.ts, src/utils/isVersionAtLeast.ts
In-memory getETag/setETag functions; isVersionAtLeast semantic-version comparator handling "??" and optional v prefix.
Credentials signature change
src/config/credentials.ts
getApiKey signature changed from (instance: URL, projectId)(apiUrl: string, projectId) and derives hostname from the string.
Logger tweak
src/utils/logger.ts
Non-TTY loading output now appends a newline after the comment.
E2E test infra & env
HACKING.md, test/e2e/__internal__/setup.ts, test/e2e/utils/api/common.ts, test/e2e/utils/run.ts
Introduced TOLGEE_TEST_BACKEND_URL support and docs; skip Docker backend when set; export API_URL from env; added runWithKill helper; CLI spawn reads API URL from env.
Watch test utilities & tests
test/e2e/utils/pullWatchUtil.ts, test/e2e/utils/sleep.ts, test/e2e/pullWatch.test.ts, test/e2e/tags.test.ts
New PullWatchUtil for filesystem polling and localization updates; sleep helper; new E2E test for pull watch; tags.test switched to exported API_URL.
Unit tests
test/unit/isVersionAtLeast.test.ts, test/unit/pullWatch/AuthErrorHandler.test.ts
Added comprehensive tests for version comparator and AuthErrorHandler behavior.
CI: Type check & Docker logs
.github/workflows/test.yml
Add Type Check job; enable RUNNER_DEBUG in e2e, capture and upload docker container logs artifact.
Minor order/cleanup
src/commands/login.ts, test/e2e/login.test.ts, test/unit/credentials.test.ts
Import reorderings, adjusted tests to match getApiKey signature; login test now logs out during teardown.
TSConfig for scripts
tsconfig.json, tsconfig.scripts.json
Removed scripts/**/* from main include and added tsconfig.scripts.json for compiling ./scripts/**/*.

Sequence Diagram(s)

sequenceDiagram
    participant User
    participant CLI as Pull CLI
    participant WS as WebSocket (STOMP/SockJS)
    participant Server
    participant Poll as Polling
    participant FS as Filesystem

    User->>CLI: run `pull --watch`
    CLI->>CLI: load stored ETag
    CLI->>WS: connect & subscribe (/projects/{id}/translation-data-modified)
    WS->>Server: STOMP SUBSCRIBE

    loop While watching
        alt Server event
            Server->>WS: translation-data-modified
            WS->>CLI: notify -> schedulePull()
        else Poll interval
            Poll->>CLI: trigger schedulePull()
        end

        CLI->>CLI: debounce (≤500ms)
        CLI->>Server: POST /v2/projects/{id}/export (If-None-Match: ETag)
        alt Changed
            Server-->>CLI: 200 + ZIP + ETag
            CLI->>FS: extract ZIP -> update files
            CLI->>CLI: setETag(newEtag)
        else Not modified
            Server-->>CLI: 304/412 (notModified)
            CLI->>CLI: skip extraction
        end
    end

    User->>CLI: SIGINT/SIGTERM
    CLI->>WS: unsubscribe & deactivate
    CLI->>Poll: clear timers
    CLI->>CLI: shutdown
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

  • Focus areas:
    • src/client/WebsocketClient.ts — reconnection/resubscribe, auth header derivation, message parsing.
    • src/utils/pullWatch/watchHandler.ts — debounce/queue logic, polling fallback, shutdown/timer cleanup.
    • src/commands/pull.ts — conditional export requests, 304/412 handling, ETag persistence.
    • src/utils/pullWatch/AuthErrorHandler.ts & src/utils/isVersionAtLeast.ts — server version retrieval and comparisons.
    • E2E tests and test/e2e/utils/run.ts — process lifecycle, runWithKill correctness.

Possibly related PRs

Poem

🐰
I nibbled bytes beneath the moonlight,
Sockets hummed and kept things tight,
ETAG guards on every door,
Watchers tapped and pulled for more,
A twitching nose: "Realtime's right!"

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 7.89% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat: pull --watch' directly and clearly describes the main feature added in this PR: a --watch option for the pull command.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch jancizmar/pull-watch

Comment @coderabbitai help to get the list of available commands and usage tips.

@JanCizmar
Copy link
Contributor Author

@coderabbitai review

@coderabbitai
Copy link

coderabbitai bot commented Aug 25, 2025

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
package.json (1)

33-47: Remove unused “ws” dependency & add SockJS types

  • No references to the “ws” package were found anywhere in the TypeScript code—only SockJS is ever instantiated for STOMP transport (Stomp.over(() => new SockJS(...)) in src/client/WebsocketClient.ts) ; you can safely remove "ws": "^8.18.3" from package.json.
  • Since you’re importing ‘sockjs-client’ directly in TS (no @ts-ignore is applied), add @types/sockjs-client to devDependencies so the compiler has proper type definitions for SockJS v1.x.
src/client/TolgeeClient.ts (1)

1-1: Fix ESLint failure: remove unused import

exitWithError is no longer used after switching to exceptions. Drop the import to get CI green.

-import { exitWithError } from './../utils/logger.js';
🧹 Nitpick comments (13)
src/client/TolgeeClient.ts (1)

29-33: Enrich LoadableError for better diagnostics (name + stack + optional cause)

Minor ergonomics: set the error name and capture a proper stack. Optionally support a cause field if an underlying fetch error exists.

 export class LoadableError extends Error {
-  constructor(public loadable: LoadableData) {
-    super(errorFromLoadable(loadable));
-  }
+  constructor(public loadable: LoadableData, cause?: unknown) {
+    super(errorFromLoadable(loadable));
+    this.name = 'LoadableError';
+    // Preserve original error in modern runtimes
+    // @ts-expect-error cause is supported in Node 18+
+    if (cause) (this as any).cause = cause;
+    if (Error.captureStackTrace) {
+      Error.captureStackTrace(this, LoadableError);
+    }
+  }
 }

Optionally, consider a type-asserting helper to improve TS narrowing:

export function assertOk(loadable: LoadableData): asserts loadable is LoadableData & { error?: undefined } {
  if (loadable.error) throw new LoadableError(loadable);
}
src/client/ExportClient.ts (1)

23-33: Normalize If-Modified-Since and consider Date input

Good call moving conditional logic to headers. Two small polish items:

  • Accept Date | string for ifModifiedSince and normalize to RFC 1123 (toUTCString()).
  • Optionally omit the headers field entirely when empty (nit).
-export type ExportRequest = Omit<
+export type ExportRequest = Omit<
   BodyOf<'/v2/projects/{projectId}/export', 'post'>,
   'zip'
 > & {
-  ifModifiedSince?: string;
+  ifModifiedSince?: string | Date;
 };
 ...
-      const { ifModifiedSince, ...exportReq } = req;
+      const { ifModifiedSince, ...exportReq } = req;
       const body = { ...exportReq, zip: true };
-      const headers: Record<string, string> = {};
-      if (ifModifiedSince) {
-        headers['If-Modified-Since'] = ifModifiedSince;
-      }
+      const headers =
+        ifModifiedSince !== undefined
+          ? {
+              'If-Modified-Since':
+                typeof ifModifiedSince === 'string'
+                  ? ifModifiedSince
+                  : ifModifiedSince.toUTCString(),
+            }
+          : undefined;
 ...
-        headers,
+        headers,

Please confirm how 304 Not Modified is surfaced by ApiClient.POST (is it a non-error with empty body, or an error?). Ensure pull.ts handles the chosen behavior correctly.

src/config/credentials.ts (1)

110-117: Enhance getApiKey resilience: support both URL and string inputs

To avoid unexpected CLI crashes when callers pass malformed URLs (or URL objects), update getApiKey in src/config/credentials.ts to accept string | URL and guard the new URL() call. Existing call sites already pass either a URL instance (in tests) or a string (from the CLI), so this change is fully backwards-compatible.

• File src/config/credentials.ts, lines 109–117:

  • Change the signature
  • Wrap the constructor in a try/catch that logs a warning and returns null on invalid input

Proposed diff:

-export async function getApiKey(
-  apiUrl: string,
+export async function getApiKey(
+  apiUrl: string | URL,
   projectId: number
 ): Promise<string | null> {
   const store = await loadStore();
 
-  const apiUrlObj = new URL(apiUrl);
+  let apiUrlObj: URL;
+  try {
+    apiUrlObj = apiUrl instanceof URL ? apiUrl : new URL(apiUrl);
+  } catch {
+    warn('Invalid API URL provided to getApiKey().');
+    return null;
+  }
 
   if (!store[apiUrlObj.hostname]) {
     // …

This will ensure:

  • Tests using new URL(...) still work seamlessly.
  • CLI calls passing a string URL will be normalized.
  • Malformed inputs no longer throw uncaught errors—instead they yield null with a warning.
src/utils/lastModifiedStorage.ts (2)

9-12: Consider scoping by host+project to avoid collisions

Keying only by projectId risks collisions if the same numeric projectId is used on multiple servers within a single process. Not critical for a typical single-target CLI run, but easy to future-proof by keying with ${hostname}:${projectId}.

Is watch mode ever intended to connect to multiple servers concurrently? If yes, I can provide a small adapter to add host scoping.


24-29: Add a clear/remove utility and optional TTL (future-proofing)

A tiny helper like clearLastModified(projectId?: number) would help tests and long-lived watch sessions. You already store timestamp; adding a simple TTL eviction (e.g., 24h) could prevent stale growth.

src/client/WebsocketClient.ts (2)

50-63: Use logger instead of console; fix callback spacing and typing; avoid trailing comma.

  • Prefer debug over console.log.
  • Prettier expects a space in function (message: any).
  • Parse to a typed envelope instead of the ad-hoc Message type for better DX.
-  const subscribeToStompChannel = (subscription: Subscription<any>) => {
+  const subscribeToStompChannel = (subscription: Subscription<ChannelProject | ChannelUser>) => {
     if (connected) {
       debug(`Subscribing to ${subscription.channel}`);
       const stompSubscription = _client!.subscribe(
         subscription.channel,
-        function(message: any) {
-          const parsed = JSON.parse(message.body) as Message;
-          subscription.callback(parsed as any);
-        },
+        function (message: any) {
+          const parsed = JSON.parse(message.body) as IncomingEvent;
+          subscription.callback(parsed as any);
+        }
       );
-      console.log('Subscribed to: ', subscription.channel, ' with id: ', stompSubscription.id);
+      debug(
+        `Subscribed to ${subscription.channel} with id: ${stompSubscription.id}`
+      );
       subscription.unsubscribe = stompSubscription.unsubscribe;
       subscription.id = stompSubscription.id;
     }
   };

Also add near the top (replace the old Message type):

-type Message = {
-  type: string;
-  actor: any;
-  data: any;
-};
+type IncomingEvent = WebsocketEvent<any> & { type?: string };

163-173: Return an empty headers object instead of null from getAuthentication.

connect expects headers; returning {} avoids null checks inside stomp client.

 function getAuthentication(options: WebsocketClientOptions) {
   if (options.authentication.jwtToken) {
     return { jwtToken: options.authentication.jwtToken };
   }
 
   if (options.authentication.apiKey) {
     return { 'x-api-key': options.authentication.apiKey };
   }
 
-  return null;
+  return {};
 }
src/utils/watchHandler.ts (4)

3-9: Remove unused imports and dead “lastModified” plumbing.

  • getLastModified and extractLastModifiedFromResponse are unused.
  • Passing lastModified through schedule/execute adds noise and is never set by the WS callback; doPull already persists last-modified.
-import {
-  getLastModified,
-  setLastModified,
-  extractLastModifiedFromResponse,
-} from './lastModifiedStorage.js';
-import { clearInterval } from 'node:timers';
+// no-op

@@
-  let lastExecutionTime = 0;
+  let lastExecutionTime = 0;
@@
-  const executePull = async (lastModified?: string) => {
+  const executePull = async () => {
     if (pulling) return;
     pulling = true;
     lastExecutionTime = Date.now();
     try {
       await doPull();
-      // Store last modified timestamp after successful pull
-      if (lastModified) {
-        setLastModified(projectId, lastModified);
-      }
     } catch (e: any) {
       error('Error during pull: ' + e.message);
       debug(e);
     } finally {
       pulling = false;
     }
   };
@@
-  const schedulePull = async (lastModified?: string) => {
+  const schedulePull = async () => {
     const now = Date.now();
     const timeSinceLastExecution = now - lastExecutionTime;
@@
-    if (timeSinceLastExecution >= SCHEDULE_PULL_DEBOUNCE_MS) {
-      await executePull(lastModified);
+    if (timeSinceLastExecution >= SCHEDULE_PULL_DEBOUNCE_MS) {
+      await executePull();
     } else {
       // Otherwise, schedule the update with debounce
       if (debounceTimer) clearTimeout(debounceTimer);
-      debounceTimer = setTimeout(() => executePull(lastModified), SCHEDULE_PULL_DEBOUNCE_MS);
+      debounceTimer = setTimeout(() => executePull(), SCHEDULE_PULL_DEBOUNCE_MS);
     }
   };

Also applies to: 35-51, 53-65


8-11: Timer API usage: avoid importing clearInterval; guard undefined handles.

Importing clearInterval from node:timers can cause typing friction and is inconsistent with the clearTimeout usage. Use globals and guard undefined.

-import { clearInterval } from 'node:timers';
+// rely on global clearInterval

@@
-  const startPolling = () => {
-    clearInterval(pollingTimer);
+  const startPolling = () => {
+    if (pollingTimer) {
+      clearInterval(pollingTimer);
+    }
     const poll = async () => {
       if (pulling) return;
       await schedulePull();
     };
@@
-    if (pollingTimer) {
-      clearInterval(pollingTimer);
-    }
+    if (pollingTimer) clearInterval(pollingTimer);

Also applies to: 68-77, 109-114


79-85: Use already-typed URL: no need to re-wrap with new URL(...).

apiUrl is already a URL. Use its origin directly.

-  const wsClient = WebsocketClient({
-    serverUrl: new URL(apiUrl).origin,
+  const wsClient = WebsocketClient({
+    serverUrl: apiUrl.origin,

97-101: Optional: surface event payload for future use.

You currently ignore the event payload. Consider capturing it for future optimizations (e.g., selective pulls or logging), even if you don’t use it yet.

src/commands/pull.ts (2)

8-14: Remove unused info import.

info isn’t used; drop it to satisfy ESLint.

 import {
   exitWithError,
   loading,
   success,
-  info,
   debug,
 } from '../utils/logger.js';

1-1: Remove unused Blob import.

Not referenced in this module.

-import { Blob } from 'buffer';
+// removed unused Blob import
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between af32420 and d4ba456.

⛔ Files ignored due to path filters (1)
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (8)
  • package.json (2 hunks)
  • src/client/ExportClient.ts (2 hunks)
  • src/client/TolgeeClient.ts (1 hunks)
  • src/client/WebsocketClient.ts (1 hunks)
  • src/commands/pull.ts (4 hunks)
  • src/config/credentials.ts (2 hunks)
  • src/utils/lastModifiedStorage.ts (1 hunks)
  • src/utils/watchHandler.ts (1 hunks)
🧰 Additional context used
🪛 GitHub Actions: Test
src/client/TolgeeClient.ts

[warning] 1-1: ESLint: 'exitWithError' is defined but never used. (no-unused-vars)

src/client/WebsocketClient.ts

[error] 55-55: Prettier formatting error. Insert · (prettier/prettier). Run 'prettier --write' to fix formatting.

🪛 GitHub Check: ESLint
src/client/WebsocketClient.ts

[failure] 199-199:
Insert ··


[failure] 198-198:
Insert ··


[failure] 130-130:
Delete ⏎······


[failure] 127-127:
Delete ,


[failure] 107-107:
Delete ,


[failure] 89-89:
Insert ·


[failure] 83-83:
Insert ·


[failure] 60-60:
Replace 'Subscribed·to:·',·subscription.channel,·'·with·id:·',·stompSubscription.id with ⏎········'Subscribed·to:·',⏎········subscription.channel,⏎········'·with·id:·',⏎········stompSubscription.id⏎······


[failure] 58-58:
Delete ,


[failure] 55-55:
Insert ·

src/utils/watchHandler.ts

[warning] 6-6:
'extractLastModifiedFromResponse' is defined but never used. Allowed unused vars must match /^_/u


[warning] 4-4:
'getLastModified' is defined but never used. Allowed unused vars must match /^_/u

src/commands/pull.ts

[warning] 12-12:
'info' is defined but never used. Allowed unused vars must match /^_/u

🔇 Additional comments (8)
src/client/TolgeeClient.ts (1)

23-27: Switch to throwing LoadableError—please verify all call sites

You've replaced the old process-exit behavior with a typed LoadableError, which is a cleaner API. Please confirm that each of the following calls to handleLoadableError(...) now either:

  • Is wrapped in a try/catch that handles LoadableError and invokes the proper exit logic, or
  • Is invoked under your global CLI error handler that catches LoadableError, formats the message, and calls process.exit(1) (or exitWithError).

Affected call sites:

  • src/commands/tag.ts (line 63)
  • src/commands/pull.ts (line 106)
  • src/commands/sync/sync.ts (lines 41, 101, 128, 169, 188)
  • src/commands/push.ts (lines 257, 267)
  • src/commands/sync/compare.ts (line 33)
  • src/client/getApiKeyInformation.ts (lines 41, 63)
src/config/credentials.ts (2)

127-129: Minor: align storage helpers with new param shape

Passing apiUrlObj into storePat is correct. No action needed—just noting the intentional shift from instance.hostname to apiUrlObj.hostname.


143-146: Expiry handling is correct—keep message consistent

The warning message correctly reflects host scoping with apiUrlObj.hostname. Looks good.

src/utils/lastModifiedStorage.ts (1)

31-35: LGTM: header extraction is straightforward

Using Response.headers.get('Last-Modified') aligns with fetch semantics in Node 18+. Looks good.

src/client/WebsocketClient.ts (2)

1-74: Formatting Fixes & CI Setup
It looks like the CI commands failed because dependencies weren’t installed, so the formatter and test runner aren’t available. Before fixing the formatting, please install the node modules and then re-run the lint/format steps.

• Install dependencies and run the formatter/tester:

pnpm install
pnpm eslint --fix .
pnpm test -s

• If you prefer using npm-scripts, ensure your package.json defines a lint script (e.g. "lint": "eslint ."), then you can do:

pnpm install
pnpm lint
pnpm test -s

• After that, apply Prettier/ESLint fixes in these ranges to satisfy CI:
– src/client/WebsocketClient.ts: lines 1–74, 83–101, 125–143, 198–199

Once the formatting errors are resolved and tests pass, CI should unblock.


163-170: Please verify WebSocket/STOMP authentication headers

I wasn’t able to find any official documentation or server-side code that confirms which headers Tolgee’s WebSocket/STOMP endpoint actually reads. Before merging, please double-check with the backend team or in the server code whether:

  • The STOMP CONNECT frame must use an Authorization: Bearer <token> header (or a custom jwtToken header)
  • The server supports X-API-Key for project API keys, or if it expects it under a different header name (e.g. X-API-Key vs. x-api-key)

Affected location:

  • src/client/WebsocketClient.ts, lines 163–170
src/commands/pull.ts (2)

69-85: LGTM: not-modified short-circuit and last-modified persistence.

The early return on notModified avoids unnecessary I/O, and persisting lastModified only after a successful extraction is sound.


111-118: Confirmed HTTP 412 is used for POST exports

Tolgee’s export endpoint returns 412 (Precondition Failed) when the If-Modified-Since header indicates no updates for a POST request, and 304 (Not Modified) only applies to GET requests. The existing code correctly checks for both status codes, so no changes are required.

- made it working
- added an option to provide an alternative backend for testing
- made it fetching the data at the beginning
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 7

♻️ Duplicate comments (2)
src/client/WebsocketClient.ts (2)

75-108: Prevent multiple concurrent connects with a "connecting" guard.

Rapid successive subscribe() calls can race and call connect() multiple times before connected flips to true. Guard with a connecting flag that resets on success or failure.

Apply this diff:

   let connected = false;
+  let connecting = false;
   let subscriptions: Subscription<any>[] = [];
   
   function connectIfNotAlready() {
     if (_deactivated) {
       return;
     }
+    
+    if (connected || connecting) {
+      return;
+    }
+    connecting = true;
 
     const client = getClient();
 
     const onConnected = function () {
       connected = true;
+      connecting = false;
       resubscribe();
       options.onConnected?.();
     };
 
     const onDisconnect = function () {
       connected = false;
+      connecting = false;
       subscriptions.forEach((s) => {
         s.unsubscribe = undefined;
         s.id = undefined;
         removeSubscription(s);
       });
       options.onConnectionClose?.();
     };
 
     const onError = (error: any) => {
+      connecting = false;
       options.onError?.(error);
     };

Based on learnings from past review comments.


88-107: Disconnect handler is never wired; subscriptions are also incorrectly removed on disconnect (breaks auto-resubscribe).

  • client.connect in stompjs does not accept a 4th onDisconnect argument, so your onDisconnect will never run.
  • Additionally, in onDisconnect you call removeSubscription(s), which wipes the in-memory list. After reconnect, resubscribe() finds nothing to re-subscribe.

Fix:

  • Wire onDisconnect via client.onWebSocketClose (and optionally onWebSocketError/onStompError).
  • Do not remove stored subscriptions on transient disconnects; just clear id/unsubscribe so resubscribe() can re-create them.

Apply this diff:

     const onDisconnect = function () {
       connected = false;
       subscriptions.forEach((s) => {
         s.unsubscribe = undefined;
         s.id = undefined;
-        removeSubscription(s);
       });
       options.onConnectionClose?.();
     };
 
     const onError = (error: any) => {
       options.onError?.(error);
     };
 
-    client.connect(
-      getAuthentication(options),
-      onConnected,
-      onError,
-      onDisconnect
-    );
+    // Wire lifecycle handlers properly
+    client.onWebSocketClose = onDisconnect;
+    client.onWebSocketError = onError;
+    // Some brokers emit STOMP errors instead of WS close
+    client.onStompError = onError;
+    // Connect (no 4th argument)
+    client.connect(getAuthentication(options) ?? {}, onConnected, onError);

Based on learnings from past review comments.

🧹 Nitpick comments (8)
test/e2e/__internal__/setup.ts (1)

6-13: LGTM! Conditional Docker startup logic is correct.

The implementation properly checks for TOLGEE_TEST_BACKEND_URL before starting the Docker backend, with helpful console logs for debugging.

Note: ESLint flagged formatting issues on lines 8 and 11 (Prettier formatting). These are purely stylistic and can be auto-fixed with npm run format.

src/utils/lastModifiedStorage.ts (2)

1-6: Consider adding cleanup for long-running watch processes.

The in-memory Map will grow indefinitely as projects are accessed. For CLI watch mode running continuously, this could become a minor memory leak over time.

Consider adding a cleanup mechanism, such as:

  • LRU eviction after a certain number of entries
  • Time-based expiration using the timestamp field
  • A periodic cleanup function

This is less critical for short-lived CLI invocations but important for long-running watch mode.


25-29: Redundant || undefined in extractLastModifiedFromResponse.

The expression response.headers.get('Last-Modified') || undefined is redundant because .get() already returns string | null, and the function return type allows undefined. The || undefined converts null to undefined but adds no meaningful value.

Simplify to:

 export function extractLastModifiedFromResponse(
   response: Response
 ): string | undefined {
-  return response.headers.get('Last-Modified') || undefined;
+  return response.headers.get('Last-Modified') ?? undefined;
 }

Or even simpler (if null is acceptable as undefined in the return type):

-): string | undefined {
-  return response.headers.get('Last-Modified') || undefined;
+): string | null | undefined {
+  return response.headers.get('Last-Modified');
 }
test/e2e/pull-watch.test.ts (1)

31-35: Consider reducing the test timeout.

A 300-second (5-minute) timeout is very long for an E2E test. Given that the test performs just two pulls with a 1-second sleep between them, a timeout of 30-60 seconds should be sufficient while still providing buffer for CI environments.

     const process = run(
       ['pull', '--api-key', pak, '--path', TMP_FOLDER, '--watch', '--verbose'],
       undefined,
-      300000
+      30000  // 30 seconds
     );
src/utils/watchHandler.ts (3)

109-124: Empty catch blocks silently suppress errors.

Lines 112 and 115 have empty catch blocks that silently swallow any errors during shutdown. While this might be intentional for cleanup code, it makes debugging difficult and could hide issues.

Consider at least logging errors at debug level:

   const shutdown = () => {
     try {
       unsubscribe?.();
-    } catch {}
+    } catch (e) {
+      debug('Error during unsubscribe:', e);
+    }
     try {
       wsClient.deactivate();
-    } catch {}
+    } catch (e) {
+      debug('Error during WebSocket deactivate:', e);
+    }
     if (pollingTimer) {
       clearInterval(pollingTimer);
     }

Based on learnings, empty catch blocks are generally discouraged unless there's a clear reason to suppress errors.


66-77: Polling runs continuously alongside WebSocket connection.

The polling mechanism starts on every update event (line 105), which means it runs continuously even when the WebSocket connection is working perfectly. This creates unnecessary load.

Consider these approaches:

  1. Option 1: Only use polling as a fallback when WebSocket fails:

    • Start polling when onError or onConnectionClose is triggered
    • Stop polling when onConnected is triggered and subscription succeeds
  2. Option 2: Use polling as a heartbeat with a longer interval (e.g., 5 minutes) to catch missed events

Example for Option 1:

let pollingActive = false;

const wsClient = WebsocketClient({
  serverUrl: new URL(apiUrl).origin,
  authentication: { apiKey: apiKey },
  onConnected: () => {
    stopPolling();  // Stop polling when WebSocket is connected
    subscribe();
  },
  onError: (error) => {
    handleAuthErrors(error, shutdown);
    startPolling();  // Start polling as fallback
    info('Websocket error encountered. Using polling fallback...');
  },
  onConnectionClose: () => {
    startPolling();  // Start polling as fallback
    info('Websocket connection closed. Using polling fallback...');
  },
});

const stopPolling = () => {
  if (pollingTimer) {
    clearInterval(pollingTimer);
    pollingTimer = undefined;
    pollingActive = false;
  }
};

const startPolling = () => {
  if (pollingActive) return;  // Don't start multiple polling intervals
  pollingActive = true;
  // ... rest of startPolling logic
};

129-134: Keep-alive pattern is correct but could be documented.

The await new Promise<void>(() => {}) on line 133 is an intentional infinite await to keep the process alive, which is correct for watch mode. However, this might be confusing to future maintainers.

Consider adding a comment to clarify the intent:

+  // Keep the process alive indefinitely in watch mode
+  // The process will only exit via SIGINT/SIGTERM handlers
   await new Promise<void>(() => {});
 }
src/client/WebsocketClient.ts (1)

161-171: Consider returning an empty object instead of null.

Line 170: Returning null when no authentication is provided might cause issues with the STOMP connect method. Consider returning an empty object {} instead, or ensure the connect call handles null properly (as suggested in the earlier fix with ?? {}).

Apply this diff:

   if (options.authentication.apiKey) {
     return { 'x-api-key': options.authentication.apiKey };
   }
 
-  return null;
+  return {};
 }
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between d4ba456 and 7536ed7.

📒 Files selected for processing (10)
  • HACKING.md (1 hunks)
  • src/client/WebsocketClient.ts (1 hunks)
  • src/commands/pull.ts (4 hunks)
  • src/utils/lastModifiedStorage.ts (1 hunks)
  • src/utils/watchHandler.ts (1 hunks)
  • test/e2e/__internal__/setup.ts (1 hunks)
  • test/e2e/pull-watch.test.ts (1 hunks)
  • test/e2e/tags.test.ts (2 hunks)
  • test/e2e/utils/api/common.ts (1 hunks)
  • test/e2e/utils/run.ts (1 hunks)
🧰 Additional context used
🪛 Biome (2.1.2)
test/e2e/pull-watch.test.ts

[error] 48-62: Do not export from a test file.

(lint/suspicious/noExportsInTest)


[error] 63-74: Do not export from a test file.

(lint/suspicious/noExportsInTest)


[error] 75-88: Do not export from a test file.

(lint/suspicious/noExportsInTest)

🪛 GitHub Check: ESLint
test/e2e/pull-watch.test.ts

[warning] 31-31:
'process' is assigned a value but never used. Allowed unused vars must match /^_/u

test/e2e/__internal__/setup.ts

[failure] 11-11:
Replace Using·alternative·backend:·${process.env.TOLGEE_TEST_BACKEND_URL} with ⏎······Using·alternative·backend:·${process.env.TOLGEE_TEST_BACKEND_URL}⏎····


[failure] 8-8:
Replace 'Starting·Docker·backend·(no·TOLGEE_TEST_BACKEND_URL·provided)' with ⏎······'Starting·Docker·backend·(no·TOLGEE_TEST_BACKEND_URL·provided)'⏎····

src/utils/watchHandler.ts

[failure] 115-115:
Empty block statement


[failure] 112-112:
Empty block statement

⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (3)
  • GitHub Check: E2E Tests (ubuntu-latest, 18)
  • GitHub Check: E2E Tests (ubuntu-latest, 22)
  • GitHub Check: E2E Tests (ubuntu-latest, 20)
🔇 Additional comments (12)
HACKING.md (1)

35-49: LGTM! Clear documentation for alternative backend configuration.

The new subsection effectively documents the TOLGEE_TEST_BACKEND_URL environment variable with practical examples. This aligns well with the test infrastructure changes introduced in this PR.

test/e2e/tags.test.ts (1)

4-4: LGTM! Good refactoring to use centralized API_URL constant.

Replacing the hardcoded URL with the imported API_URL constant improves maintainability and ensures consistency across test files.

Also applies to: 25-25

test/e2e/utils/run.ts (1)

40-41: LGTM! Environment-driven backend URL configuration is correct.

The fallback to http://localhost:22222 ensures backward compatibility while enabling alternative backend testing.

test/e2e/utils/api/common.ts (1)

6-7: LGTM! Exporting API_URL centralizes backend URL configuration.

Making API_URL an exported constant allows test files to consistently reference the configured backend, improving maintainability.

src/utils/lastModifiedStorage.ts (1)

1-6: Verify: Is in-memory storage acceptable for watch mode?

The comment notes "In the future we can use filesystem to store it." For the watch mode use case, in-memory storage means:

  • Last-Modified state is lost when the CLI process restarts
  • First pull after restart will fetch all data (no If-Modified-Since header)

This is likely acceptable for short-lived CLI watch sessions, but please confirm this behavior aligns with user expectations. If watch mode is intended for long-running server-side deployments, filesystem persistence would provide better UX.

src/utils/watchHandler.ts (1)

100-107: Verify: Subscription logic appears to unsubscribe immediately on first event.

The current implementation calls unsubscribe?.() inside the subscription handler (line 102), which appears to unsubscribe immediately upon receiving the first event. This seems incorrect if the intent is to keep watching for changes.

Looking at the flow:

  1. Line 101: Subscribe to channel
  2. Event received → Line 102: Unsubscribe
  3. Line 104: Schedule pull
  4. Line 105: Start polling

This pattern suggests the WebSocket is only used for the initial trigger, then falls back to polling. However, this defeats the purpose of a WebSocket connection for real-time updates.

Expected behavior questions:

  1. Should the subscription remain active to receive multiple update events?
  2. Or is the intent to receive one event, then rely on polling?
  3. Why unsubscribe after each event if the onConnected callback will re-subscribe?

If you want continuous WebSocket updates, remove the unsubscribe() call from inside the handler:

   function subscribe() {
     unsubscribe = wsClient.subscribe(channel, () => {
-      unsubscribe?.();
       debug('Data change detected by websocket. Pulling now... ');
       schedulePull();
-      startPolling();
     });
   }

And consider whether startPolling() should only be called as a fallback when WebSocket connection fails, not on every event.

src/commands/pull.ts (3)

40-67: LGTM!

The watch/non-watch orchestration is clear and delegates appropriately to either a one-shot doPull or the continuous startWatching flow.


191-196: LGTM!

The --watch option is correctly defined with an appropriate description and default value.


88-119: The export API correctly supports the ifModifiedSince parameter.

The ExportRequest type includes ifModifiedSince?: string, and the export method properly converts it to the If-Modified-Since HTTP header for conditional requests. The implementation in pull.ts correctly passes this parameter to the API, which will handle the 304/412 status codes appropriately.

src/client/WebsocketClient.ts (3)

124-141: LGTM, pending the connectIfNotAlready fixes.

The subscribe function correctly orchestrates connection and subscription management. However, it depends on the fixes to connectIfNotAlready to avoid race conditions.


143-156: LGTM!

The disconnect, deactivate, and removeSubscription functions are straightforward and correctly implemented.


173-257: LGTM!

The type definitions are well-structured and provide strong type safety for channel subscriptions and event payloads. The use of conditional types in Data<T> ensures type-correct callbacks for each channel.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

♻️ Duplicate comments (4)
src/utils/watchHandler.ts (1)

135-143: Fix err(...) typo to call the logger’s error(...) function.

handleAuthErrors calls err("You're not authorized. Insufficient permissions?"), but there is no err function imported. This will throw at runtime and prevent shutdown() from executing correctly.

Replace err(...) with the imported error(...):

   if (err?.headers?.message == 'Unauthorized') {
-    err("You're not authorized. Insufficient permissions?");
+    error("You're not authorized. Insufficient permissions?");
     shutdown();
   }
src/client/WebsocketClient.ts (3)

51-63: Harden subscription callback: guard _client and handle malformed JSON.

subscribeToStompChannel assumes _client is non-null and that message.body is always valid JSON. A bad message would currently crash the process.

Consider:

-  const subscribeToStompChannel = (subscription: Subscription<any>) => {
-    if (connected) {
+  const subscribeToStompChannel = (subscription: Subscription<any>) => {
+    if (!connected || !_client) {
+      return;
+    }
+    try {
       debug(`Subscribing to ${subscription.channel}`);
-      const stompSubscription = _client!.subscribe(
+      const stompSubscription = _client.subscribe(
         subscription.channel,
         function (message: any) {
-          const parsed = JSON.parse(message.body) as Message;
-          subscription.callback(parsed as any);
+          try {
+            const parsed = JSON.parse(message.body) as Message;
+            subscription.callback(parsed as any);
+          } catch (error) {
+            debug(
+              `Failed to parse message from ${subscription.channel}: ` +
+                (error as any)?.message
+            );
+          }
         }
       );
       subscription.unsubscribe = stompSubscription.unsubscribe;
       subscription.id = stompSubscription.id;
-    }
+    } catch (error) {
+      debug(
+        `Failed to subscribe to ${subscription.channel}: ` +
+          (error as any)?.message
+      );
+    }
   };
For @stomp/stompjs CompatClient, does `client.subscribe` ever invoke the callback with non-JSON payloads or structured error frames that might not match `{ body: string }`? Verify recommended error-handling patterns for message parsing.

66-74: Validate serverUrl before constructing the SockJS endpoint.

initClient assumes options.serverUrl is defined and concatenates it into a URL string. If callers omit serverUrl, you’ll end up connecting to "undefined/websocket".

Consider enforcing a non-empty serverUrl:

  function initClient() {
-    _client = Stomp.over(() => new SockJS(`${options.serverUrl}/websocket`));
+    if (!options.serverUrl) {
+      throw new Error('serverUrl is required for WebSocket connection');
+    }
+    const base = options.serverUrl.replace(/\/+$/, '');
+    _client = Stomp.over(() => new SockJS(`${base}/websocket`));
What is the recommended way to configure SockJS endpoint URLs with @stomp/stompjs and sockjs-client (e.g., expected base URL format and trailing slash handling)?

103-108: Pass an object (not null) as headers to client.connect.

getAuthentication(options) returns null when neither JWT token nor API key is provided, but @stomp/stompjs CompatClient.connect expects TypeScript-typed StompHeaders object, not null. Although the library accepts null at runtime due to its varargs implementation, this violates the library's type contract and should be avoided.

+    const headers = getAuthentication(options) ?? {};
-    client.connect(
-      getAuthentication(options),
+    client.connect(
+      headers,
       onConnected,
       onError,
       onDisconnect
     );
🧹 Nitpick comments (2)
src/utils/watchHandler.ts (2)

108-120: Avoid empty catch {} blocks or at least document why errors are ignored.

Both try { unsubscribe?.(); } catch {} and try { wsClient.deactivate(); } catch {} silently swallow all errors and trigger ESLint’s “empty block statement” warnings. This can hide real shutdown issues and complicate debugging.

Consider at least logging at debug level or adding a comment to justify intentional suppression:

-  const shutdown = () => {
-    try {
-      unsubscribe?.();
-    } catch {}
-    try {
-      wsClient.deactivate();
-    } catch {}
+  const shutdown = () => {
+    try {
+      unsubscribe?.();
+    } catch (e) {
+      debug('Error while unsubscribing websocket: ' + (e as any)?.message);
+    }
+    try {
+      wsClient.deactivate();
+    } catch (e) {
+      debug('Error while deactivating websocket client: ' + (e as any)?.message);
+    }

[static_analysis_hints]


66-77: Consider starting the polling backup loop immediately, not only after the first WS event.

startPolling() is called only from the WebSocket message handler. If the WebSocket connects but no translation-data-modified events are emitted (or events are missed), there will be no periodic polling, and changes may only be picked up on future WS events.

If you want a true “backup” poller independent of WS messages, you might call startPolling() once during initialization:

  subscribe();
- schedulePull();
+ schedulePull();
+ startPolling();
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 7536ed7 and f0aedc1.

📒 Files selected for processing (2)
  • src/client/WebsocketClient.ts (1 hunks)
  • src/utils/watchHandler.ts (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
src/utils/watchHandler.ts (3)
src/utils/logger.ts (4)
  • info (42-44)
  • error (69-71)
  • debug (31-35)
  • success (51-53)
src/utils/lastModifiedStorage.ts (1)
  • setLastModified (18-23)
src/client/WebsocketClient.ts (1)
  • WebsocketClient (32-160)
🪛 GitHub Check: ESLint
src/utils/watchHandler.ts

[failure] 114-114:
Empty block statement


[failure] 111-111:
Empty block statement

⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (3)
  • GitHub Check: E2E Tests (ubuntu-latest, 20)
  • GitHub Check: E2E Tests (ubuntu-latest, 18)
  • GitHub Check: E2E Tests (ubuntu-latest, 22)

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

♻️ Duplicate comments (4)
test/e2e/pullWatch.test.ts (2)

49-93: Move helper functions to a utility module.

Biome correctly flags that exporting functions from test files is problematic. These helpers (waitFilesystemDataUpdated, isFilesystemDataUpdated, changeLocalizationData) should be moved to test/e2e/utils/ for better organization and reusability.


28-46: Critical: Missing await and process cleanup causing test reliability issues.

  1. Line 29: changeLocalizationData('A key') should be awaited to ensure data is set before the watch process starts.

  2. Lines 31-35: The spawned process is never terminated, causing resource leaks and potential 5-minute hangs.

   it('pulls strings from Tolgee with watch', async () => {
-    changeLocalizationData('A key');
+    await changeLocalizationData('A key');
 
-    const process = run(
+    const watchProcess = run(
       ['pull', '--api-key', pak, '--path', TMP_FOLDER, '--watch', '--verbose'],
       undefined,
       300000
     );
 
     // Tests that it pulls at the beginning...
     await waitFilesystemDataUpdated('A key');
 
     // We need to sleep a bit to make sure the Last-Modified header is updated.
     // The Last-Modified has second precision, so we need to wait at least 1 second.
     await sleep(1000);
     // Tests that it pulls after change...
     await changeLocalizationData('Another key');
     await waitFilesystemDataUpdated('Another key');
+
+    // Clean up: terminate the watch process
+    // Note: You may need to modify run() to expose the child process for termination
+    try {
+      (await watchProcess).kill?.('SIGTERM');
+    } catch { /* Process may have already exited */ }
   });
src/client/WebsocketClient.ts (2)

51-64: Add error handling for JSON.parse and validate client state.

This concern was raised in a previous review and remains unaddressed:

  1. Line 57: JSON.parse(message.body) can throw if the message body is malformed, causing the subscription callback to fail unexpectedly.
  2. Line 52-54: The function checks connected but uses _client! without verifying _client is non-null. Defensive programming suggests checking both.

Apply this diff to add error handling:

   const subscribeToStompChannel = (subscription: Subscription<any>) => {
-    if (connected) {
+    if (connected && _client) {
       debug(`Subscribing to ${subscription.channel}`);
-      const stompSubscription = _client!.subscribe(
+      const stompSubscription = _client.subscribe(
         subscription.channel,
         function (message: any) {
-          const parsed = JSON.parse(message.body) as Message;
-          subscription.callback(parsed as any);
+          try {
+            const parsed = JSON.parse(message.body) as Message;
+            subscription.callback(parsed as any);
+          } catch (error) {
+            debug(`Failed to parse message from ${subscription.channel}: ${error}`);
+          }
         }
       );
       subscription.unsubscribe = stompSubscription.unsubscribe;
       subscription.id = stompSubscription.id;
     }
   };

66-74: Validate serverUrl before constructing the SockJS URL.

This concern was raised in a previous review and remains unaddressed:

Line 67: If options.serverUrl is undefined or empty, the URL construction will produce an invalid URL like undefined/websocket, causing the WebSocket connection to fail.

Apply this diff to validate serverUrl:

   function initClient() {
+    if (!options.serverUrl) {
+      throw new Error('serverUrl is required for WebSocket connection');
+    }
     _client = Stomp.over(() => new SockJS(`${options.serverUrl}/websocket`));
     _client.configure({
       reconnectDelay: 3000,
       debug: (msg: string) => {
         debug(msg);
       },
     });
   }
🧹 Nitpick comments (4)
src/utils/pullWatch/AuthErrorHandler.ts (2)

32-49: Redundant client parameter - already available via closure.

The isAppSupportedVersion function receives client as a parameter, but client is already available in the outer closure from AuthErrorHandler. The same applies to getTolgeeServerVersion.

-  async function isAppSupportedVersion(
-    client: ReturnType<typeof createTolgeeClient>
-  ) {
+  async function isAppSupportedVersion() {
     const serverVersion = await getTolgeeServerVersion(client);
     // ...
   }

-  async function getTolgeeServerVersion(
-    client: ReturnType<typeof createTolgeeClient>
-  ): Promise<string | null> {
+  async function getTolgeeServerVersion(): Promise<string | null> {

9-14: Use strict equality (===) for string comparisons.

While == works here, === is the idiomatic choice in TypeScript for comparing strings.

-    if (err?.headers?.message == 'Unauthenticated') {
+    if (err?.headers?.message === 'Unauthenticated') {
       await printUnauthenticatedError();
     }
-    if (err?.headers?.message == 'Unauthorized') {
+    if (err?.headers?.message === 'Unauthorized') {
       error("You're not authorized. Insufficient permissions?");
     }
src/utils/pullWatch/watchHandler.ts (2)

85-93: Use imported error() logger instead of console.error for consistency.

The module imports error from ../logger.js but uses console.error here. For consistent log formatting across the CLI, use the imported logger.

     onError: (error) => {
-      console.error('Error: handling auth error');
+      debug('WebSocket error received, checking auth...');
       AuthErrorHandler(client)
         .handleAuthErrors(error, shutdown)
         .catch((err: any) => {
           debug('Error in handleAuthErrors: ' + err);
         });
       // Non-fatal: just inform
       info('Websocket error encountered. Reconnecting...');
     },

102-102: Fix type annotation for unsubscribe.

The type () => void | undefined means a function returning void | undefined, but the intent is that unsubscribe itself can be undefined. Consider:

-  let unsubscribe: () => void | undefined;
+  let unsubscribe: (() => void) | undefined;
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between f0aedc1 and 852308b.

📒 Files selected for processing (8)
  • src/client/TolgeeClient.ts (1 hunks)
  • src/client/WebsocketClient.ts (1 hunks)
  • src/commands/pull.ts (4 hunks)
  • src/utils/isVersionAtLeast.ts (1 hunks)
  • src/utils/pullWatch/AuthErrorHandler.ts (1 hunks)
  • src/utils/pullWatch/watchHandler.ts (1 hunks)
  • test/e2e/pullWatch.test.ts (1 hunks)
  • test/unit/isVersionAtLeast.test.ts (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (2)
  • src/client/TolgeeClient.ts
  • src/commands/pull.ts
🧰 Additional context used
🧬 Code graph analysis (6)
src/utils/isVersionAtLeast.ts (1)
src/utils/logger.ts (1)
  • debug (31-35)
test/e2e/pullWatch.test.ts (5)
src/client/TolgeeClient.ts (1)
  • TolgeeClient (34-34)
test/e2e/utils/tmp.ts (2)
  • setupTemporaryFolder (9-23)
  • TMP_FOLDER (7-7)
test/e2e/utils/api/common.ts (3)
  • createProjectWithClient (49-92)
  • createPak (106-112)
  • deleteProject (39-43)
test/e2e/utils/api/project1.ts (1)
  • PROJECT_1 (6-15)
test/e2e/utils/run.ts (1)
  • run (98-105)
src/utils/pullWatch/AuthErrorHandler.ts (3)
src/client/TolgeeClient.ts (1)
  • createTolgeeClient (12-20)
src/utils/logger.ts (2)
  • error (69-71)
  • debug (31-35)
src/utils/isVersionAtLeast.ts (1)
  • isVersionAtLeast (7-35)
src/utils/pullWatch/watchHandler.ts (5)
src/client/TolgeeClient.ts (1)
  • createTolgeeClient (12-20)
src/utils/logger.ts (4)
  • info (42-44)
  • error (69-71)
  • debug (31-35)
  • success (51-53)
src/utils/lastModifiedStorage.ts (1)
  • setLastModified (18-23)
src/client/WebsocketClient.ts (1)
  • WebsocketClient (32-160)
src/utils/pullWatch/AuthErrorHandler.ts (1)
  • AuthErrorHandler (5-65)
test/unit/isVersionAtLeast.test.ts (1)
src/utils/isVersionAtLeast.ts (1)
  • isVersionAtLeast (7-35)
src/client/WebsocketClient.ts (1)
src/utils/logger.ts (2)
  • error (69-71)
  • debug (31-35)
🪛 Biome (2.1.2)
test/e2e/pullWatch.test.ts

[error] 48-62: Do not export from a test file.

(lint/suspicious/noExportsInTest)


[error] 63-79: Do not export from a test file.

(lint/suspicious/noExportsInTest)


[error] 80-93: Do not export from a test file.

(lint/suspicious/noExportsInTest)

🪛 GitHub Check: ESLint
test/e2e/pullWatch.test.ts

[warning] 31-31:
'process' is assigned a value but never used. Allowed unused vars must match /^_/u

src/utils/pullWatch/watchHandler.ts

[failure] 118-118:
Empty block statement


[failure] 115-115:
Empty block statement

⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (4)
  • GitHub Check: Unit Tests (windows-latest, 20)
  • GitHub Check: E2E Tests (ubuntu-latest, 22)
  • GitHub Check: E2E Tests (ubuntu-latest, 18)
  • GitHub Check: E2E Tests (ubuntu-latest, 20)
🔇 Additional comments (3)
src/utils/isVersionAtLeast.ts (1)

7-35: LGTM! Clean semver comparison implementation.

The logic correctly handles version comparison with proper early returns. The handling of ?? for local builds and stripping v prefixes is well-documented.

One edge case to be aware of: versions with non-numeric suffixes (e.g., 1.0.0-beta) will produce NaN values from map(Number), which could cause unexpected comparison results. If pre-release version support is needed in the future, consider using a dedicated semver library.

test/unit/isVersionAtLeast.test.ts (1)

1-117: LGTM! Comprehensive test coverage.

Excellent test organization with clear describe blocks covering equality, greater-than/less-than semantics, edge cases (local build ??), prefix handling, and mixed-length versions. The test cases provide strong confidence in the implementation's correctness.

src/utils/pullWatch/watchHandler.ts (1)

21-137: LGTM overall - well-structured watch handler.

The implementation correctly:

  • Uses debouncing to prevent excessive pulls
  • Has a polling fallback mechanism
  • Handles graceful shutdown with signal handlers
  • Integrates with lastModified storage for HTTP caching
  • Properly guards against concurrent pulls with the pulling flag

@JanCizmar JanCizmar marked this pull request as ready for review December 12, 2025 13:00
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

♻️ Duplicate comments (2)
test/e2e/pullWatch.test.ts (2)

49-93: Move helper functions to a utility module or remove exports.

Biome correctly flags that exporting from test files is problematic. If these helpers are only used within this file, remove the export keywords. If they need to be shared across tests, move them to test/e2e/utils/.

Quick fix if helpers are only used locally:

-export async function waitFilesystemDataUpdated(aKeyValue: string) {
+async function waitFilesystemDataUpdated(aKeyValue: string) {
-export async function isFilesystemDataUpdated(newValue: string) {
+async function isFilesystemDataUpdated(newValue: string) {
-export async function changeLocalizationData(newEnText: string) {
+async function changeLocalizationData(newEnText: string) {

31-46: Process cleanup is still missing - test reliability concern.

The spawned watch process is assigned to process but never terminated. This will cause:

  • The process to run for up to 5 minutes until the timeout
  • Potential resource leaks and port conflicts between tests
  • ESLint warning about unused variable

Apply this diff to properly clean up the process:

-    const process = run(
+    const childProcess = run(
       ['pull', '--api-key', pak, '--path', TMP_FOLDER, '--watch', '--verbose'],
       undefined,
       300000
     );
 
     // Tests that it pulls at the beginning...
     await waitFilesystemDataUpdated('A key');
 
     // We need to sleep a bit to make sure the Last-Modified header is updated.
     // The Last-Modified has second precision, so we need to wait at least 1 second.
     await sleep(1000);
     // Tests that it pulls after change...
     await changeLocalizationData('Another key');
     await waitFilesystemDataUpdated('Another key');
+
+    // Clean up the watch process
+    const result = await childProcess;
+    // Note: You may need to modify run() to expose the underlying process for termination

Note: The run() utility returns Promise<RunResult>. You may need to extend it to provide a way to terminate long-running watch processes, or use a different approach that gives you control over the child process lifecycle.

🧹 Nitpick comments (6)
test/unit/pullWatch/AuthErrorHandler.test.ts (3)

20-20: Remove stale comment.

This comment references removed code and provides no value.

-// Mock console.log to capture output (removed since it's not working properly with vi.mock)

61-65: Rename local error variable to avoid shadowing the imported error function.

The local variable error shadows the imported error function from the logger module, which can cause confusion when reading the tests.

-      const error = {
+      const authError = {
         headers: {
           message: 'Unauthenticated',
         },
       };
 
-      await authErrorHandler.handleAuthErrors(error, mockShutdown);
+      await authErrorHandler.handleAuthErrors(authError, mockShutdown);

Apply the same renaming pattern to other test cases (lines 91, 112, 138, 155, 170, 209).


169-182: Consider testing with null or undefined error input.

The current "other errors" test uses an error with a different message. Consider also testing how handleAuthErrors behaves when called with null, undefined, or an error object without a headers property, as these could occur in real-world scenarios.

it('should handle null/undefined errors gracefully', async () => {
  await authErrorHandler.handleAuthErrors(null, mockShutdown);
  expect(mockShutdown).toHaveBeenCalled();
  
  vi.clearAllMocks();
  
  await authErrorHandler.handleAuthErrors(undefined, mockShutdown);
  expect(mockShutdown).toHaveBeenCalled();
});

it('should handle errors without headers property', async () => {
  await authErrorHandler.handleAuthErrors({ message: 'error' }, mockShutdown);
  expect(mockShutdown).toHaveBeenCalled();
});
src/utils/pullWatch/AuthErrorHandler.ts (3)

8-15: Use strict equality (===) for string comparisons.

Lines 9 and 12 use loose equality (==). While this works for string-to-string comparison, strict equality is the idiomatic choice in TypeScript.

   async function handleAuthErrors(err: any, shutdown: () => void) {
-    if (err?.headers?.message == 'Unauthenticated') {
+    if (err?.headers?.message === 'Unauthenticated') {
       await printUnauthenticatedError();
     }
-    if (err?.headers?.message == 'Unauthorized') {
+    if (err?.headers?.message === 'Unauthorized') {
       error("You're not authorized. Insufficient permissions?");
     }
     shutdown();
   }

24-27: Improve error message when server version is unknown.

When serverVersion is null, the error message displays "Server version null does not support..." which is confusing for users. Consider a more informative message.

+  async function printUnauthenticatedError() {
+    const { isSupported, serverVersion } = await isAppSupportedVersion(client);
+    if (isSupported) {
+      error("You're not authenticated. Invalid API key?");
+      return;
+    }
+    if (!serverVersion) {
+      error(
+        `Could not determine server version. CLI watch mode requires Tolgee server version ${REQUIRED_VERSION} or higher.`
+      );
+      return;
+    }
     error(
       `Server version ${serverVersion} does not support CLI watch mode. Please update your Tolgee server to version ${REQUIRED_VERSION} or higher.`
     );
-    return;
   }

Note: This would require updating the corresponding unit test expectations.


65-65: Consider moving constant to the top of the file.

REQUIRED_VERSION is defined at the bottom but used throughout the module. Convention typically places constants near the top after imports for better visibility.

 import { debug, error } from '../logger.js';
 import { isVersionAtLeast } from '../isVersionAtLeast.js';
 import { createTolgeeClient } from '../../client/TolgeeClient.js';
+
+const REQUIRED_VERSION = '3.143.0';
 
 export function AuthErrorHandler(

And remove from line 65.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 852308b and b2801eb.

📒 Files selected for processing (3)
  • src/utils/pullWatch/AuthErrorHandler.ts (1 hunks)
  • test/e2e/pullWatch.test.ts (1 hunks)
  • test/unit/pullWatch/AuthErrorHandler.test.ts (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (3)
test/unit/pullWatch/AuthErrorHandler.test.ts (4)
src/client/TolgeeClient.ts (1)
  • createTolgeeClient (12-20)
src/utils/pullWatch/AuthErrorHandler.ts (1)
  • AuthErrorHandler (5-63)
src/utils/isVersionAtLeast.ts (1)
  • isVersionAtLeast (7-35)
src/utils/logger.ts (1)
  • error (69-71)
src/utils/pullWatch/AuthErrorHandler.ts (3)
src/client/TolgeeClient.ts (1)
  • createTolgeeClient (12-20)
src/utils/logger.ts (2)
  • error (69-71)
  • debug (31-35)
src/utils/isVersionAtLeast.ts (1)
  • isVersionAtLeast (7-35)
test/e2e/pullWatch.test.ts (5)
src/client/TolgeeClient.ts (1)
  • TolgeeClient (34-34)
test/e2e/utils/tmp.ts (2)
  • setupTemporaryFolder (9-23)
  • TMP_FOLDER (7-7)
test/e2e/utils/api/common.ts (3)
  • createProjectWithClient (49-92)
  • createPak (106-112)
  • deleteProject (39-43)
test/e2e/utils/api/project1.ts (1)
  • PROJECT_1 (6-15)
test/e2e/utils/run.ts (1)
  • run (98-105)
🪛 Biome (2.1.2)
test/e2e/pullWatch.test.ts

[error] 48-62: Do not export from a test file.

(lint/suspicious/noExportsInTest)


[error] 63-79: Do not export from a test file.

(lint/suspicious/noExportsInTest)


[error] 80-93: Do not export from a test file.

(lint/suspicious/noExportsInTest)

🪛 GitHub Check: ESLint
test/unit/pullWatch/AuthErrorHandler.test.ts

[failure] 220-220:
Replace "You're·not·authenticated.·Invalid·API·key?" with ⏎············"You're·not·authenticated.·Invalid·API·key?"⏎··········


[failure] 217-217:
Replace '3.143.0',·testCase.version with ⏎··········'3.143.0',⏎··········testCase.version⏎········


[failure] 165-165:
Replace "You're·not·authorized.·Insufficient·permissions?" with ⏎········"You're·not·authorized.·Insufficient·permissions?"⏎······


[failure] 101-101:
Replace '3.143.0',·unsupportedVersion with ⏎········'3.143.0',⏎········unsupportedVersion⏎······


[failure] 72-72:
Replace "You're·not·authenticated.·Invalid·API·key?" with ⏎········"You're·not·authenticated.·Invalid·API·key?"⏎······

test/e2e/pullWatch.test.ts

[warning] 31-31:
'process' is assigned a value but never used. Allowed unused vars must match /^_/u

⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (3)
  • GitHub Check: E2E Tests (ubuntu-latest, 18)
  • GitHub Check: E2E Tests (ubuntu-latest, 20)
  • GitHub Check: E2E Tests (ubuntu-latest, 22)
🔇 Additional comments (1)
src/utils/pullWatch/AuthErrorHandler.ts (1)

5-62: Overall structure looks good.

The factory pattern with a frozen return object provides good encapsulation. Internal helpers are properly scoped, and error handling is appropriately defensive with optional chaining and try-catch blocks.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 9

♻️ Duplicate comments (2)
src/commands/pull.ts (1)

69-86: Dynamic import of setLastModified is inconsistent (and already raised before).

If there’s no circular-dep reason, statically import setLastModified alongside getLastModified/extractLastModifiedFromResponse.

src/client/WebsocketClient.ts (1)

8-17: Make serverUrl required (or validate) before constructing SockJS URL.

Right now ${options.serverUrl}/websocket can become undefined/websocket if the parameter is omitted, since serverUrl is optional in the type definition but used without validation on line 71.

 type WebsocketClientOptions = {
-  serverUrl?: string;
+  serverUrl: string;
   authentication: {
     jwtToken?: string;
     apiKey?: string;
   };
   onConnected?: (message: any) => void;
   onError?: (error: any) => void;
   onConnectionClose?: () => void;
 };

If making it required is not feasible, add a runtime guard in initClient().

🧹 Nitpick comments (3)
test/e2e/utils/sleep.ts (1)

1-3: Add an explicit return type (Promise<void>) for clarity/TS strictness.

-export function sleep(ms: number) {
+export function sleep(ms: number): Promise<void> {
   return new Promise((resolve) => setTimeout(resolve, ms));
 }
test/e2e/utils/pullWatchUtil.ts (1)

9-22: Polling loop looks fine; consider making timeout configurable for slower CI.

Hardcoding maxAttempts = 1000 and sleep(10) is ~10s. If CI is slower, making these optional params (or env-driven) can reduce flakes.

src/utils/pullWatch/watchHandler.ts (1)

82-98: Avoid mixing console.* with your logger + clarify WS error severity.

Prefer error(...)/debug(...) over console.error('Error: handling auth error') to keep output consistent (and controllable via --verbose).

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between b2801eb and 735b9b0.

📒 Files selected for processing (9)
  • src/client/WebsocketClient.ts (1 hunks)
  • src/commands/pull.ts (4 hunks)
  • src/utils/lastModifiedStorage.ts (1 hunks)
  • src/utils/pullWatch/watchHandler.ts (1 hunks)
  • test/e2e/__internal__/setup.ts (1 hunks)
  • test/e2e/pullWatch.test.ts (1 hunks)
  • test/e2e/utils/pullWatchUtil.ts (1 hunks)
  • test/e2e/utils/sleep.ts (1 hunks)
  • test/unit/pullWatch/AuthErrorHandler.test.ts (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (3)
  • test/unit/pullWatch/AuthErrorHandler.test.ts
  • src/utils/lastModifiedStorage.ts
  • test/e2e/internal/setup.ts
🧰 Additional context used
🧬 Code graph analysis (4)
test/e2e/utils/pullWatchUtil.ts (3)
src/client/TolgeeClient.ts (1)
  • TolgeeClient (34-34)
test/e2e/utils/sleep.ts (1)
  • sleep (1-3)
test/e2e/utils/tmp.ts (1)
  • TMP_FOLDER (7-7)
src/client/WebsocketClient.ts (1)
src/utils/logger.ts (2)
  • error (69-71)
  • debug (31-35)
src/commands/pull.ts (7)
src/utils/logger.ts (3)
  • exitWithError (103-116)
  • debug (31-35)
  • info (42-44)
src/utils/checkPathNotAFile.ts (1)
  • checkPathNotAFile (4-18)
src/utils/pullWatch/watchHandler.ts (1)
  • startWatching (21-141)
src/utils/lastModifiedStorage.ts (3)
  • getLastModified (5-13)
  • setLastModified (15-19)
  • extractLastModifiedFromResponse (21-25)
src/utils/prepareDir.ts (1)
  • prepareDir (4-15)
src/utils/zip.ts (1)
  • unzipBuffer (32-45)
src/client/TolgeeClient.ts (1)
  • handleLoadableError (22-26)
src/utils/pullWatch/watchHandler.ts (5)
src/client/TolgeeClient.ts (1)
  • createTolgeeClient (12-20)
src/utils/logger.ts (4)
  • info (42-44)
  • error (69-71)
  • debug (31-35)
  • success (51-53)
src/utils/lastModifiedStorage.ts (1)
  • setLastModified (15-19)
src/client/WebsocketClient.ts (1)
  • WebsocketClient (32-164)
src/utils/pullWatch/AuthErrorHandler.ts (1)
  • AuthErrorHandler (5-63)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (3)
  • GitHub Check: E2E Tests (ubuntu-latest, 22)
  • GitHub Check: E2E Tests (ubuntu-latest, 20)
  • GitHub Check: E2E Tests (ubuntu-latest, 18)
🔇 Additional comments (1)
src/commands/pull.ts (1)

88-120: The use of If-Modified-Since with POST is non-standard HTTP; verify Tolgee server's actual response.

Sending If-Modified-Since header with a POST request deviates from HTTP specification, which reserves If-Modified-Since for conditional GET/HEAD requests. Per HTTP specs, conditional headers on POST should use If-Match or If-Unmodified-Since (which return 412 Precondition Failed on failure). The Tolgee server may return 304, 412, 200, or handle this unexpectedly. Verify the actual status codes the Tolgee export endpoint returns for this scenario, and consider whether a GET request or standard conditional headers would be more appropriate.

@JanCizmar JanCizmar changed the title fix: pull --watch feat: pull --watch Dec 12, 2025
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (2)
src/utils/pullWatch/watchHandler.ts (1)

83-113: Polling fallback still won’t start if WS never delivers a message.
startPolling() is only called from the subscription callback (Line 123-124). If the WS connection fails early (or auth fails), polling never starts, despite being described as a backup.

Suggestion: start polling immediately (or at least on onError / onConnectionClose) and keep startPolling() idempotent (it already clears any existing interval).

Also applies to: 119-125, 151-156

src/client/WebsocketClient.ts (1)

70-78: Validate/normalize options.serverUrl before creating SockJS client.
serverUrl is optional (Line 9) but used unconditionally (Line 71). This can build undefined/websocket at runtime in other call sites.

   function initClient() {
-    _client = Stomp.over(() => new SockJS(`${options.serverUrl}/websocket`));
+    if (!options.serverUrl) {
+      throw new Error('WebsocketClient: serverUrl is required');
+    }
+    const base = options.serverUrl.replace(/\/+$/, '');
+    _client = Stomp.over(() => new SockJS(`${base}/websocket`));
     _client.configure({
       reconnectDelay: 3000,
       debug: (msg: string) => {
         debug(msg);
       },
     });
   }
🧹 Nitpick comments (3)
test/e2e/utils/run.ts (1)

107-117: LGTM! Appropriate for watch mode test orchestration.

The function provides manual process control needed for watch mode tests while maintaining the existing timeout safety net. The API is clean and follows established patterns in this file.

Optionally, you could capture the return value of kill() for additional robustness:

 export function runWithKill(
   args: string[],
   env?: Record<string, string>,
   timeout = 10e3
 ) {
   const cliProcess = spawn(args, false, env);
   return {
     promise: runProcess(cliProcess, timeout),
-    kill: (signal: NodeJS.Signals) => cliProcess.kill(signal),
+    kill: (signal: NodeJS.Signals) => {
+      const success = cliProcess.kill(signal);
+      if (!success) {
+        console.warn(`Failed to send signal ${signal} to process ${cliProcess.pid}`);
+      }
+      return success;
+    },
   };
 }

This would help debug test failures if process termination doesn't work as expected, though the existing implementation is adequate for the test utility's purpose.

src/utils/pullWatch/watchHandler.ts (1)

119-125: Avoid “floating” async call from WS callback (prevent unhandled rejections).
Line 122 calls schedulePull() without awaiting. Even if today it’s unlikely to throw, it’s safer to make intent explicit:

-      schedulePull();
+      void schedulePull();
src/client/WebsocketClient.ts (1)

96-105: Consider clearing per-subscription id/unsubscribe on disconnect (stale handles across reconnects).
On reconnect, resubscribe() re-subscribes, but old subscription.unsubscribe/subscription.id can remain stale and misleading. Clearing them on onDisconnect is cheap and keeps internal state consistent.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 735b9b0 and 3f73678.

📒 Files selected for processing (4)
  • src/client/WebsocketClient.ts (1 hunks)
  • src/utils/pullWatch/watchHandler.ts (1 hunks)
  • test/e2e/pullWatch.test.ts (1 hunks)
  • test/e2e/utils/run.ts (2 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • test/e2e/pullWatch.test.ts
🧰 Additional context used
🧬 Code graph analysis (1)
src/utils/pullWatch/watchHandler.ts (5)
src/client/TolgeeClient.ts (1)
  • createTolgeeClient (12-20)
src/utils/logger.ts (4)
  • info (42-44)
  • error (69-71)
  • debug (31-35)
  • success (51-53)
src/utils/lastModifiedStorage.ts (1)
  • setLastModified (15-19)
src/client/WebsocketClient.ts (1)
  • WebsocketClient (32-164)
src/utils/pullWatch/AuthErrorHandler.ts (1)
  • AuthErrorHandler (5-63)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (3)
  • GitHub Check: E2E Tests (ubuntu-latest, 22)
  • GitHub Check: E2E Tests (ubuntu-latest, 18)
  • GitHub Check: E2E Tests (ubuntu-latest, 20)
🔇 Additional comments (2)
test/e2e/utils/run.ts (1)

41-41: Good addition for test flexibility.

Using the environment variable allows E2E tests to target different backend instances while maintaining a sensible default.

src/client/WebsocketClient.ts (1)

51-68: Message parsing guard looks good.
Try/catch around JSON.parse + logging prevents malformed payloads from crashing the client.

- Last modified has 1s precision, which is not optimal
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

♻️ Duplicate comments (3)
src/commands/pull.ts (1)

66-85: Avoid logging ETag: undefined + prefer static import for setETag (if no circular dep).

  • Guard the info log so it doesn’t print undefined.
  • Unless there’s a known circular dependency reason, the dynamic import for setETag is inconsistent with the rest of the file.
src/utils/pullWatch/watchHandler.ts (2)

40-64: Fix broken “pending pull” logic (follow-up pull is currently dropped).

executePull() is invoked while pulling === true (Line 57-62), so the recursive call immediately sets pending = true and returns; then pulling is set to false and nothing re-triggers the pending pull.

   const executePull = async (etag?: string) => {
     if (pulling) {
       pending = true;
       return;
     }
     pulling = true;
@@
     } finally {
-      // If there was a pending pull (data changed when pulling), execute it now
-      if (pending) {
-        pending = false;
-        void executePull();
-      }
       pulling = false;
+      // If there was a pending pull (data changed when pulling), execute it now
+      if (pending) {
+        pending = false;
+        void executePull();
+      }
     }
   };

83-124: Polling fallback still won’t start if WS never connects / never emits.

startPolling() is only called inside the subscription callback (Line 120-124), so a dead WS connection means no fallback polling at all.

🧹 Nitpick comments (1)
src/utils/eTagStorage.ts (1)

1-25: LGTM; consider storing Map<number, string> unless you expect metadata soon.

Current { etag: string } wrapper adds indirection without benefit unless you plan to extend stored fields imminently.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 3f73678 and 3090d2f.

📒 Files selected for processing (5)
  • src/client/ExportClient.ts (2 hunks)
  • src/commands/pull.ts (4 hunks)
  • src/utils/eTagStorage.ts (1 hunks)
  • src/utils/pullWatch/watchHandler.ts (1 hunks)
  • test/e2e/pullWatch.test.ts (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/client/ExportClient.ts
🧰 Additional context used
🧬 Code graph analysis (2)
src/utils/pullWatch/watchHandler.ts (5)
src/client/TolgeeClient.ts (1)
  • createTolgeeClient (12-20)
src/utils/logger.ts (4)
  • info (42-44)
  • error (69-71)
  • debug (31-35)
  • success (51-53)
src/utils/eTagStorage.ts (1)
  • setETag (15-19)
src/client/WebsocketClient.ts (1)
  • WebsocketClient (32-164)
src/utils/pullWatch/AuthErrorHandler.ts (1)
  • AuthErrorHandler (5-63)
test/e2e/pullWatch.test.ts (6)
src/client/TolgeeClient.ts (1)
  • TolgeeClient (34-34)
test/e2e/utils/pullWatchUtil.ts (1)
  • PullWatchUtil (6-54)
test/e2e/utils/tmp.ts (2)
  • setupTemporaryFolder (9-23)
  • TMP_FOLDER (7-7)
test/e2e/utils/api/common.ts (1)
  • createProjectWithClient (49-92)
test/e2e/utils/api/project1.ts (1)
  • PROJECT_1 (6-15)
test/e2e/utils/run.ts (1)
  • runWithKill (107-117)
🪛 GitHub Check: ESLint
test/e2e/pullWatch.test.ts

[failure] 47-47:
'newEnText' is never reassigned. Use 'const' instead

⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (3)
  • GitHub Check: E2E Tests (ubuntu-latest, 20)
  • GitHub Check: E2E Tests (ubuntu-latest, 22)
  • GitHub Check: E2E Tests (ubuntu-latest, 18)
🔇 Additional comments (1)
src/commands/pull.ts (1)

87-119: Remove ifNoneMatch parameter: Tolgee API does not support conditional POSTs with If-None-Match headers.

The Tolgee API export endpoint (POST /v2/projects/:projectId/export) does not document or support ETag-based conditional requests with If-None-Match headers. ETag/If-None-Match is designed for conditional GETs, not POSTs. Passing ifNoneMatch to opts.client.export.export() will be silently ignored by the API, and the response will never include a 412 or 304 status code. The code currently expects conditional behavior that the API does not provide.

If conditional export caching is needed, implement client-side caching or request a feature from Tolgee.

(Note: handleLoadableError is safe to call before status checks since it only throws if loadable.error is set, not based on HTTP status codes.)

Likely an incorrect or invalid review comment.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

♻️ Duplicate comments (1)
src/commands/pull.ts (1)

69-69: Guard against logging ETag: undefined.

When result.etag is absent, this will log "ETag: undefined". Consider only logging when present or adjusting the message format.

Apply this diff to guard the log:

-  info(`ETag: ${result.etag} (${new Date().toLocaleString()})`);
+  if (result.etag) {
+    info(`ETag: ${result.etag} (${new Date().toLocaleString()})`);
+  }
🧹 Nitpick comments (2)
src/utils/logger.ts (1)

125-135: Nice non-TTY output fix; consider guarding against double-newlines in comment.

process.stdout.write(comment + '\n') makes CI/non-interactive logs much cleaner. Only possible nit: if any caller passes a comment already ending in \n, this will add an extra blank line—optional to normalize via comment.replace(/\n$/, '') (or similar).

src/commands/pull.ts (1)

74-75: Consolidate the dynamic import of setETag.

You're statically importing getETag and extractETagFromResponse from the same module on line 13, but dynamically importing setETag here. Unless there's a specific reason (e.g., circular dependency), consolidate all three at the top for consistency.

Apply this diff to consolidate imports:

 import {
   extractETagFromResponse,
   getETag,
+  setETag,
 } from '../utils/eTagStorage.js';

And update the usage:

   // Store ETag after a successful pull
   if (result.etag) {
-    const { setETag } = await import('../utils/eTagStorage.js');
     setETag(opts.projectId, result.etag);
   }
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 3090d2f and 46ba86e.

📒 Files selected for processing (2)
  • src/commands/pull.ts (4 hunks)
  • src/utils/logger.ts (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
src/commands/pull.ts (7)
src/utils/logger.ts (3)
  • exitWithError (103-116)
  • loading (125-155)
  • info (42-44)
src/utils/checkPathNotAFile.ts (1)
  • checkPathNotAFile (4-18)
src/utils/pullWatch/watchHandler.ts (1)
  • startWatching (21-156)
src/utils/eTagStorage.ts (3)
  • getETag (5-13)
  • setETag (15-19)
  • extractETagFromResponse (21-25)
src/utils/prepareDir.ts (1)
  • prepareDir (4-15)
src/utils/zip.ts (1)
  • unzipBuffer (32-45)
src/client/TolgeeClient.ts (1)
  • handleLoadableError (22-26)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (4)
  • GitHub Check: Unit Tests (windows-latest, 22)
  • GitHub Check: E2E Tests (ubuntu-latest, 22)
  • GitHub Check: E2E Tests (ubuntu-latest, 20)
  • GitHub Check: E2E Tests (ubuntu-latest, 18)
🔇 Additional comments (2)
src/commands/pull.ts (2)

30-58: LGTM! Clean separation of concerns.

The refactored handler properly validates the path, checks it's not a file, then branches between single-pull and watch modes. The watch mode integration with startWatching is well-structured.


79-111: Verify the Tolgee API's handling of conditional requests with 412 status code.

The ifNoneMatch parameter is properly supported and correctly passed to the export API via the If-None-Match header. However, the code treats HTTP 412 (Precondition Failed) as "not modified" on line 109, which contradicts standard HTTP semantics. In standard HTTP, 412 indicates the precondition failed (ETag doesn't match), not that the resource is unchanged. Please confirm this is intentional Tolgee API behavior by checking the API documentation, or clarify if 412 should instead trigger an error condition rather than be treated as "data unchanged."

@JanCizmar JanCizmar requested a review from zomp December 15, 2025 13:43
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

♻️ Duplicate comments (2)
src/utils/pullWatch/watchHandler.ts (2)

105-112: onError handler has conflicting logic.

When AuthErrorHandler encounters an authentication or authorization error, it calls shutdown() which exits the process (line 149). However, line 112 then logs "Reconnecting..." which will never execute after shutdown. This makes the "Reconnecting..." message misleading.

Consider restructuring the error handling to only call shutdown() for auth errors, and allow reconnection for other WebSocket errors:

   onError: (error) => {
+    const isAuthError = error?.headers?.message === 'Unauthenticated' || 
+                        error?.headers?.message === 'Forbidden';
+    if (isAuthError) {
       AuthErrorHandler(client)
         .handleAuthErrors(error, shutdown)
         .catch((err: any) => {
           debug('Error in handleAuthErrors: ' + err);
         });
+      return;
+    }
     // Non-fatal: just inform
     info('Websocket error encountered. Reconnecting...');
+    startPolling();
   },

88-100: Polling never starts if WebSocket fails to connect or receive events.

The startPolling() function is only invoked inside the WebSocket event handler (line 127), meaning if the WebSocket connection fails or never receives a translation-data-modified event, polling never begins. This undermines the "backup" mechanism described in the comment at line 8.

Consider starting polling immediately after its definition (e.g., call startPolling() at line 101) or in the WebSocket onError and onConnectionClose handlers to ensure it serves as a true fallback.

Apply this diff to start polling immediately:

   // Set up periodic polling
   pollingTimer = setInterval(poll, POLLING_INTERVAL_SECONDS * 1000);
 };
+
+ // Start polling immediately as a fallback
+ startPolling();

Or add to error handlers:

   onError: (error) => {
     AuthErrorHandler(client)
       .handleAuthErrors(error, shutdown)
       .catch((err: any) => {
         debug('Error in handleAuthErrors: ' + err);
       });
     // Non-fatal: just inform
     info('Websocket error encountered. Reconnecting...');
+    startPolling();
   },
   onConnectionClose: () => {
     info('Websocket connection closed. Attempting to reconnect...');
+    startPolling();
   },
🧹 Nitpick comments (4)
src/utils/pullWatch/AuthErrorHandler.ts (4)

8-18: Use strict equality operators.

Lines 9 and 14 use loose equality (==) when comparing err?.headers?.message. Prefer strict equality (===) for consistency and to avoid unexpected type coercion.

Apply this diff:

   async function handleAuthErrors(err: any, shutdown: () => void) {
-    if (err?.headers?.message == 'Unauthenticated') {
+    if (err?.headers?.message === 'Unauthenticated') {
       await printUnauthenticatedError();
       shutdown();
       return;
     }
-    if (err?.headers?.message == 'Forbidden') {
+    if (err?.headers?.message === 'Forbidden') {
       error("You're not authorized. Insufficient permissions?");
       shutdown();
       return;
     }
   }

21-31: Optional: Remove unnecessary return statements.

The explicit return statements at lines 25 and 30 are not needed since the function returns Promise<void> and there's no code after these branches.

Apply this diff:

   async function printUnauthenticatedError() {
     const { isSupported, serverVersion } = await isAppSupportedVersion(client);
     if (isSupported) {
       error("You're not authenticated. Invalid API key?");
-      return;
     }
-    error(
-      `Server version ${serverVersion} does not support CLI watch mode. Please update your Tolgee server to version ${REQUIRED_VERSION} or higher.`
-    );
-    return;
+    else {
+      error(
+        `Server version ${serverVersion} does not support CLI watch mode. Please update your Tolgee server to version ${REQUIRED_VERSION} or higher.`
+      );
+    }
   }

33-50: Use ES6 object property shorthand.

Lines 40-43 and 46-49 can use object property shorthand since the property name matches the variable name.

Apply this diff:

   async function isAppSupportedVersion(
     client: ReturnType<typeof createTolgeeClient>
   ) {
     const serverVersion = await getTolgeeServerVersion(client);

     if (!serverVersion) {
       debug('Could not determine server version');
       return {
         isSupported: false,
-        serverVersion: serverVersion,
+        serverVersion,
       };
     }

     return {
       isSupported: isVersionAtLeast(REQUIRED_VERSION, serverVersion),
-      serverVersion: serverVersion,
+      serverVersion,
     };
   }

68-68: Consider moving constant to the top of the file.

The REQUIRED_VERSION constant is defined after the factory function, which works due to hoisting but is unconventional. Consider moving it near the imports (e.g., after line 3) for better code organization and readability.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 46ba86e and 57d87f9.

📒 Files selected for processing (3)
  • src/utils/pullWatch/AuthErrorHandler.ts (1 hunks)
  • src/utils/pullWatch/watchHandler.ts (1 hunks)
  • test/e2e/pullWatch.test.ts (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • test/e2e/pullWatch.test.ts
🧰 Additional context used
🧬 Code graph analysis (1)
src/utils/pullWatch/watchHandler.ts (5)
src/client/TolgeeClient.ts (1)
  • createTolgeeClient (12-20)
src/utils/logger.ts (4)
  • info (42-44)
  • error (69-71)
  • debug (31-35)
  • success (51-53)
src/utils/eTagStorage.ts (1)
  • setETag (15-19)
src/client/WebsocketClient.ts (1)
  • WebsocketClient (32-164)
src/utils/pullWatch/AuthErrorHandler.ts (1)
  • AuthErrorHandler (5-66)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (3)
  • GitHub Check: E2E Tests (ubuntu-latest, 22)
  • GitHub Check: E2E Tests (ubuntu-latest, 18)
  • GitHub Check: E2E Tests (ubuntu-latest, 20)
🔇 Additional comments (8)
src/utils/pullWatch/AuthErrorHandler.ts (1)

52-63: LGTM!

The function correctly fetches the server version from the configuration endpoint and handles errors gracefully with debug logging.

src/utils/pullWatch/watchHandler.ts (7)

1-11: LGTM!

Imports and constants are well-organized and appropriately documented.


13-19: LGTM!

The WatchHandlerOptions type is well-defined with appropriate fields for the watch functionality.


41-68: LGTM!

The pending pull logic correctly captures the ETag when a pull is requested during an active pull, then triggers a follow-up pull with the captured ETag after the current pull completes.


70-85: LGTM!

The debouncing logic correctly executes immediately if enough time has passed or schedules with a delay otherwise, preventing excessive pull operations.


119-129: Subscription logic is correct.

The WebSocket subscription correctly schedules a pull when translation data changes are detected.


131-150: LGTM!

The shutdown function properly cleans up all resources (WebSocket subscription, timers) and gracefully exits the process.


152-160: Initialization structure is sound.

The signal handlers ensure graceful shutdown, and the immediate schedulePull() triggers an initial pull before watching begins. The pending promise correctly keeps the process alive.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (1)
src/commands/pull.ts (1)

136-137: Consolidate the setETag import at the top.

This dynamic import was flagged in previous review comments. Since getETag is already statically imported from the same module (line 13), setETag should also be imported statically unless there's a circular dependency.

Apply this diff to consolidate the import:

-import { getETag } from '../utils/eTagStorage.js';
+import { getETag, setETag } from '../utils/eTagStorage.js';

Then update the usage:

   // Store ETag after a successful pull
   if (result.etag) {
-    const { setETag } = await import('../utils/eTagStorage.js');
     setETag(opts.projectId, result.etag);
   }
🧹 Nitpick comments (1)
src/commands/pull.ts (1)

175-177: LGTM! Helper correctly extracts ETag header.

The function appropriately handles the case where the ETag header is absent by returning undefined.

Optional: You could use nullish coalescing (??) instead of logical OR (||) for stricter null handling:

-  return response.headers.get('ETag') || undefined;
+  return response.headers.get('ETag') ?? undefined;

This only matters if an empty-string ETag is possible (which is unlikely).

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between ebc1753 and 4872470.

📒 Files selected for processing (3)
  • src/commands/pull.ts (2 hunks)
  • src/utils/eTagStorage.ts (1 hunks)
  • test/e2e/utils/run.ts (3 hunks)
🚧 Files skipped from review as they are similar to previous changes (2)
  • test/e2e/utils/run.ts
  • src/utils/eTagStorage.ts
🧰 Additional context used
🧬 Code graph analysis (1)
src/commands/pull.ts (8)
src/utils/logger.ts (3)
  • exitWithError (103-116)
  • success (51-53)
  • info (42-44)
src/utils/checkPathNotAFile.ts (1)
  • checkPathNotAFile (4-18)
src/utils/pullWatch/watchHandler.ts (1)
  • startWatching (21-160)
src/utils/eTagStorage.ts (2)
  • getETag (5-13)
  • setETag (15-19)
src/utils/prepareDir.ts (1)
  • prepareDir (4-15)
src/utils/mapExportFormat.ts (1)
  • mapExportFormat (9-96)
src/client/TolgeeClient.ts (1)
  • handleLoadableError (22-26)
src/schema.d.ts (1)
  • Schema (78-250)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (3)
  • GitHub Check: E2E Tests (ubuntu-latest, 22)
  • GitHub Check: E2E Tests (ubuntu-latest, 20)
  • GitHub Check: E2E Tests (ubuntu-latest, 18)
🔇 Additional comments (5)
src/commands/pull.ts (5)

7-7: LGTM! New imports support watch functionality.

The additional imports (info, loading, startWatching, getETag) are all used appropriately throughout the file to implement the watch mode feature.

Also applies to: 12-13


84-89: LGTM! Watch option is well-defined.

The --watch option follows the established pattern and appropriately defaults to false.


92-120: LGTM! Handler correctly branches between single-pull and watch modes.

The refactored handler:

  • Validates the path upfront before any mode-specific logic
  • Cleanly separates single-pull (lines 104-108) and watch (lines 111-119) workflows
  • Properly wraps doPull for the watch handler

179-192: LGTM! Type definition correctly includes watch field.

The PullOptions type is properly extended with the optional watch field.


168-171: Confirm this behavior against Tolgee API documentation.

The code uses status 412 to detect "not modified" for POST requests with If-None-Match, which is non-standard HTTP behavior. While the code comment acknowledges this design (referencing MDN's 412 status), proper verification requires confirming this matches Tolgee's actual API specification. Consider linking to the export endpoint documentation in the comment for future clarity.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

🧹 Nitpick comments (2)
.github/workflows/test.yml (2)

74-75: Consider removing commented code or documenting its purpose.

The commented-out Docker pull step adds clutter. If this is intentionally left for future debugging, consider adding a comment explaining why. Otherwise, remove it to keep the workflow clean.


78-79: Consider removing or conditionalizing RUNNER_DEBUG.

RUNNER_DEBUG: 1 enables verbose GitHub Actions logging, which is typically used during development. If this was added for debugging and is no longer needed, consider removing it to reduce log verbosity in production runs.

📜 Review details

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 4872470 and 08675e0.

📒 Files selected for processing (1)
  • .github/workflows/test.yml (1 hunks)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (4)
  • GitHub Check: Unit Tests (windows-latest, 20)
  • GitHub Check: E2E Tests (ubuntu-latest, 22)
  • GitHub Check: E2E Tests (ubuntu-latest, 18)
  • GitHub Check: E2E Tests (ubuntu-latest, 20)
🔇 Additional comments (2)
.github/workflows/test.yml (2)

87-93: LGTM! Artifact upload is well-configured.

The step correctly uses if: always() to ensure logs are uploaded even on test failure, includes the Node version in the artifact name for traceability, and sets a reasonable 7-day retention period.


82-86: Good implementation of log capture with appropriate error handling.

The step correctly uses if: always() to capture logs even when tests fail, and includes proper error handling with the || echo fallback. The container name tolgee_cli_e2e correctly matches the container created by npm run tolgee:start.

@JanCizmar JanCizmar force-pushed the jancizmar/pull-watch branch from 56cd67d to f2130f3 Compare December 17, 2025 10:07
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
test/e2e/login.test.ts (1)

36-37: Good addition for test isolation.

Adding the logout command in afterAll ensures authentication state is properly cleaned up between test suites, preventing potential pollution in subsequent tests.

Minor optional suggestion: The comment could be slightly clearer as "Cleanup authentication to prevent pollution to next tests" but this is purely stylistic.

📜 Review details

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 08675e0 and f2130f3.

📒 Files selected for processing (3)
  • .github/workflows/test.yml (1 hunks)
  • src/commands/login.ts (2 hunks)
  • test/e2e/login.test.ts (2 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • .github/workflows/test.yml
🧰 Additional context used
🧬 Code graph analysis (2)
src/commands/login.ts (2)
src/utils/logger.ts (1)
  • debug (31-35)
src/utils/getStackTrace.ts (1)
  • getStackTrace (1-8)
test/e2e/login.test.ts (1)
test/e2e/utils/run.ts (1)
  • run (98-104)

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

♻️ Duplicate comments (1)
src/client/WebsocketClient.ts (1)

70-78: Validate serverUrl before constructing the SockJS URL.

If options.serverUrl is undefined, this produces an invalid URL undefined/websocket. Add validation to fail fast with a clear error.

Apply this diff:

 function initClient() {
+  if (!options.serverUrl) {
+    throw new Error('serverUrl is required for WebSocket connection');
+  }
   client = Stomp.over(() => new SockJS(`${options.serverUrl}/websocket`));
🧹 Nitpick comments (2)
src/commands/pull.ts (1)

122-138: Consider consolidating the setETag import at the top of the file.

The getETag is statically imported at line 13, but setETag is dynamically imported here. Unless there's a specific reason (circular dependency, lazy loading for performance), consolidate for consistency.

Apply this diff:

-import { getETag } from '../utils/eTagStorage.js';
+import { getETag, setETag } from '../utils/eTagStorage.js';

And update the usage:

   // Store ETag after a successful pull
   if (result.etag) {
-    const { setETag } = await import('../utils/eTagStorage.js');
     setETag(opts.projectId, result.etag);
   }

Based on the past review comment that flagged the same pattern with setLastModified.

src/client/WebsocketClient.ts (1)

85-113: Avoid shadowing the module-level client variable.

Line 87 declares const client = getClient() which shadows the let client at line 33. While functionally correct (both reference the same object), this shadowing is confusing and error-prone for future modifications.

Apply this diff:

   connecting = true;

-  const client = getClient();
+  const stompClient = getClient();

   const onConnected = function (message: any) {
     connected = true;
     connecting = false;
     resubscribe();
     options.onConnected?.(message);
   };

   const onDisconnect = function () {
     connected = false;
     connecting = false;
     options.onConnectionClose?.();
   };

   const onError = (error: any) => {
     connecting = false;
     options.onError?.(error);
   };

-  client.connect(
+  stompClient.connect(
     getAuthentication(options),
     onConnected,
     onError,
     onDisconnect
   );
 }
📜 Review details

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between f2130f3 and a690988.

⛔ Files ignored due to path filters (1)
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (6)
  • .github/workflows/test.yml (2 hunks)
  • package.json (3 hunks)
  • src/client/WebsocketClient.ts (1 hunks)
  • src/commands/login.ts (1 hunks)
  • src/commands/pull.ts (2 hunks)
  • test/unit/credentials.test.ts (3 hunks)
🚧 Files skipped from review as they are similar to previous changes (2)
  • package.json
  • src/commands/login.ts
🧰 Additional context used
🧬 Code graph analysis (1)
test/unit/credentials.test.ts (1)
src/config/credentials.ts (1)
  • getApiKey (109-153)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (3)
  • GitHub Check: E2E Tests (ubuntu-latest, 18)
  • GitHub Check: E2E Tests (ubuntu-latest, 22)
  • GitHub Check: E2E Tests (ubuntu-latest, 20)
🔇 Additional comments (8)
test/unit/credentials.test.ts (1)

115-119: LGTM! Test updates correctly adapt to the new API signature.

The test calls to getApiKey have been correctly updated to pass string URLs (TG_X.toString()) instead of URL objects, aligning with the updated function signature that now accepts apiUrl: string as the first parameter.

Also applies to: 129-129, 137-137

.github/workflows/test.yml (3)

27-44: LGTM: Well-structured type check job.

The new type-checking job follows the established pattern and appropriately runs on a single Node version, as type checks are runtime-independent.


94-95: Verify RUNNER_DEBUG placement.

Setting RUNNER_DEBUG in a step's env block may not enable runner diagnostics as intended. This variable typically needs to be set at the workflow or job level to control GitHub Actions runner debug logging.

If debug logging is needed for the entire test-e2e job, consider moving it to the job level:

 test-e2e:
+  env:
+    RUNNER_DEBUG: 1
   strategy:
     matrix:

Alternatively, verify whether step-level RUNNER_DEBUG achieves the desired debugging behavior for this specific step.


98-109: LGTM: Good observability enhancement.

The Docker log capture and upload steps are well-implemented with proper error handling and if: always() conditions to ensure logs are available for debugging failed test runs. The container name tolgee_cli_e2e correctly matches the container started by the startDocker.ts script.

src/commands/pull.ts (2)

140-172: Well-structured ETag-based conditional fetch implementation.

The fetchZipBlob function correctly:

  • Passes ifNoneMatch to the export API
  • Extracts ETag from response headers
  • Handles both 412 (POST) and 304 (GET) as "not modified" per HTTP semantics

The comment at lines 167-169 explaining the 412 usage is helpful for future maintainers.


84-120: Clean branching for watch mode.

The handler correctly validates path upfront, then branches between single pull and watch mode. The startWatching integration passes the required context and a doPull callback.

src/client/WebsocketClient.ts (2)

32-68: Robust connection state management and error handling.

The implementation correctly:

  • Uses a connecting guard to prevent concurrent connection attempts
  • Wraps JSON.parse in try-catch with error logging
  • Validates both connected and client before subscribing
  • Stores subscription metadata for resubscription on reconnect

178-262: Comprehensive type definitions for WebSocket events.

The type system provides strong typing for project/user channels and their corresponding payloads (TranslationsModifiedData, BatchJobProgress). The Data<T> conditional type elegantly maps channel patterns to their event types.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

♻️ Duplicate comments (1)
src/commands/login.ts (1)

9-9: Remove debugging code before merging.

This debug statement logs sensitive information (even a truncated API key should never be logged) and includes a stack trace that provides no value for the login operation. This appears to be debugging code accidentally left in and is unrelated to the PR's stated objective of adding watch mode to the pull command.

Security and maintenance concerns:

  • API keys are sensitive credentials that should never appear in logs, even partially masked
  • The stack trace serves no purpose in the login flow
  • The debug and getStackTrace imports are only used for this statement

Apply this diff to remove the debugging code:

-import { debug, exitWithError, success } from '../utils/logger.js';
+import { exitWithError, success } from '../utils/logger.js';
-import { getStackTrace } from '../utils/getStackTrace.js';
-  debug(
-    `Logging in with API key ${key?.slice(0, 5)}...${key?.slice(-4)}.\n${getStackTrace()}`
-  );
-

Also applies to: 12-12, 30-32

🧹 Nitpick comments (1)
test/e2e/error.test.ts (1)

31-32: Good addition for test isolation; consider adding error handling.

Adding the logout step in afterAll correctly prevents authentication state from leaking between test suites, improving test reliability.

However, consider wrapping the logout in a try-catch block similar to the pattern in afterEach (lines 14-20). If the logout operation fails or throws, it could cause the entire afterAll hook to fail, potentially masking the actual test results or making debugging harder.

Apply this diff to add defensive error handling:

   afterAll(async () => {
     await deleteProject(client);
-    // cleanup after logout, otherwise it pollutes to next tests
-    await run(['logout']);
+    // logout for cleanup, otherwise it pollutes to next tests
+    try {
+      await run(['logout']);
+    } catch (e) {
+      // Ignore logout errors during cleanup to avoid masking test failures
+      console.warn('Logout failed during cleanup:', e);
+    }
   });

Note: The comment wording has been adjusted slightly for clarity.

📜 Review details

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between a7e8f80 and 2db567d.

📒 Files selected for processing (2)
  • src/commands/login.ts (2 hunks)
  • test/e2e/error.test.ts (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (2)
src/commands/login.ts (2)
src/utils/logger.ts (1)
  • debug (31-35)
src/utils/getStackTrace.ts (1)
  • getStackTrace (1-8)
test/e2e/error.test.ts (1)
test/e2e/utils/run.ts (1)
  • run (98-104)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (3)
  • GitHub Check: E2E Tests (ubuntu-latest, 22)
  • GitHub Check: E2E Tests (ubuntu-latest, 20)
  • GitHub Check: E2E Tests (ubuntu-latest, 18)

@JanCizmar JanCizmar force-pushed the jancizmar/pull-watch branch from f0ed141 to 4bff501 Compare December 17, 2025 12:37
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (2)
test/e2e/pullWatch.test.ts (2)

4-4: Fix import formatting to match project style.

ESLint expects multiline formatting for this import statement.

Apply this diff:

-import { createPak, createProjectWithClient, deleteProject, } from './utils/api/common.js';
+import {
+  createPak,
+  createProjectWithClient,
+  deleteProject,
+} from './utils/api/common.js';

47-47: Add comment explaining the sleep delay.

The 1-second sleep between starting the watch process and the first update iteration lacks explanation. Consider adding a comment clarifying why this delay is necessary (e.g., waiting for watch initialization, WebSocket connection, etc.).

+    // Wait for watch process to initialize and establish WebSocket connection
     await sleep(1000);
📜 Review details

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 4bff501 and d04d13d.

📒 Files selected for processing (1)
  • test/e2e/pullWatch.test.ts (1 hunks)
🧰 Additional context used
🪛 GitHub Check: ESLint
test/e2e/pullWatch.test.ts

[failure] 4-4:
Replace ·createPak,·createProjectWithClient,·deleteProject,· with ⏎··createPak,⏎··createProjectWithClient,⏎··deleteProject,⏎

⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (3)
  • GitHub Check: E2E Tests (ubuntu-latest, 20)
  • GitHub Check: E2E Tests (ubuntu-latest, 18)
  • GitHub Check: E2E Tests (ubuntu-latest, 22)
🔇 Additional comments (3)
test/e2e/pullWatch.test.ts (3)

10-25: LGTM! Test setup and teardown are properly structured.

The test lifecycle correctly initializes resources in beforeEach and cleans them up in afterEach, ensuring test isolation.


49-54: LGTM! Watch process management and cleanup are correct.

The test properly terminates the watch process with SIGTERM and awaits its exit before completing, preventing race conditions with test cleanup. The 10-iteration loop provides thorough coverage of the continuous watch behavior.


56-64: LGTM! Helper function is well-structured.

The testFetchAfterUpdate helper cleanly encapsulates a single watch-update-verify cycle with appropriate logging for debugging. The use of const for newEnText is correct.

@JanCizmar JanCizmar force-pushed the jancizmar/pull-watch branch from e79ab6a to 53bd130 Compare December 17, 2025 12:50
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

♻️ Duplicate comments (1)
src/utils/pullWatch/watchHandler.ts (1)

87-100: Polling fallback may never start if WebSocket fails to connect.

startPolling() is only invoked inside the WebSocket message callback (line 133), meaning if the WebSocket connection fails initially or errors before any message arrives, polling never starts. Consider starting polling immediately as a true fallback, or invoking startPolling() in onError/onConnectionClose handlers.

   const wsClient = WebsocketClient({
     serverUrl: new URL(apiUrl).origin,
     authentication: { apiKey: apiKey },
     onConnected: () => {
       debug(
         'WebSocket connected and subscriptions active. Performing initial pull...'
       );
       schedulePull();
     },
     onError: (error) => {
       AuthErrorHandler(client)
         .handleAuthErrors(error, shutdown)
         .catch((err: any) => {
           debug('Error in handleAuthErrors: ' + err);
         });
       // Non-fatal: just inform
       info('Websocket error encountered. Reconnecting...');
+      startPolling();
     },
     onConnectionClose: () => {
       info('Websocket connection closed. Attempting to reconnect...');
+      startPolling();
     },
   });
🧹 Nitpick comments (3)
src/utils/pullWatch/watchHandler.ts (3)

111-118: onError flow may log misleading "Reconnecting..." after auth errors call shutdown.

AuthErrorHandler.handleAuthErrors calls shutdown() (which exits the process) for auth errors, but the code continues to info('Websocket error encountered. Reconnecting...'). While process.exit(0) prevents this message from appearing for auth errors, the control flow is unclear. Consider returning early after handleAuthErrors if it handled the error:

     onError: (error) => {
-      AuthErrorHandler(client)
-        .handleAuthErrors(error, shutdown)
-        .catch((err: any) => {
-          debug('Error in handleAuthErrors: ' + err);
-        });
-      // Non-fatal: just inform
-      info('Websocket error encountered. Reconnecting...');
+      AuthErrorHandler(client)
+        .handleAuthErrors(error, shutdown)
+        .then(() => {
+          // Auth errors call shutdown(); non-auth errors reach here
+          info('Websocket error encountered. Reconnecting...');
+          startPolling();
+        })
+        .catch((err: any) => {
+          debug('Error in handleAuthErrors: ' + err);
+        });
     },

129-135: Consider performing initial pull and starting polling on subscribe.

Currently, startPolling() is only called when a WebSocket message is received. If no messages arrive (e.g., no changes on the server), the polling fallback never activates. You may want to start polling immediately after subscribing as a safety net:

   function subscribe() {
     unsubscribe = wsClient.subscribe(channel, () => {
       debug('Data change detected by websocket. Pulling now... ');
       schedulePull();
-      startPolling();
     });
+    // Start polling as a fallback immediately
+    startPolling();
   }

163-164: Consider documenting the never-resolving promise pattern.

await new Promise<void>(() => {}) is a valid idiom to keep the process alive, but a brief comment would help future maintainers understand the intent:

+  // Keep process alive indefinitely; shutdown() handles termination via process.exit()
   await new Promise<void>(() => {});
📜 Review details

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between d04d13d and 53bd130.

📒 Files selected for processing (2)
  • src/utils/pullWatch/watchHandler.ts (1 hunks)
  • test/e2e/pullWatch.test.ts (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (2)
test/e2e/pullWatch.test.ts (6)
src/client/TolgeeClient.ts (1)
  • TolgeeClient (34-34)
test/e2e/utils/pullWatchUtil.ts (1)
  • PullWatchUtil (6-54)
test/e2e/utils/tmp.ts (2)
  • setupTemporaryFolder (9-23)
  • TMP_FOLDER (7-7)
test/e2e/utils/api/common.ts (1)
  • createProjectWithClient (49-92)
test/e2e/utils/api/project1.ts (1)
  • PROJECT_1 (6-15)
test/e2e/utils/run.ts (2)
  • run (106-112)
  • runWithKill (114-125)
src/utils/pullWatch/watchHandler.ts (4)
src/client/TolgeeClient.ts (1)
  • createTolgeeClient (12-20)
src/utils/logger.ts (4)
  • info (42-44)
  • error (69-71)
  • debug (31-35)
  • success (51-53)
src/utils/eTagStorage.ts (1)
  • setETag (15-19)
src/client/WebsocketClient.ts (1)
  • WebsocketClient (32-164)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: E2E Tests (ubuntu-latest, 20)
🔇 Additional comments (6)
test/e2e/pullWatch.test.ts (4)

1-28: Test setup looks well-structured.

The test properly initializes the temporary folder, project, PAK, and utility instance in beforeEach, and cleans up via deleteProject in afterEach. The use of PullWatchUtil encapsulates the filesystem and API interactions cleanly.


50-55: Process cleanup pattern is correct.

The test now properly awaits both the kill('SIGTERM') and the promise resolution, ensuring the watch process terminates before afterEach cleanup runs. This addresses the prior race condition concern.


57-65: Helper function is clear and well-scoped.

testFetchAfterUpdate encapsulates the update-and-verify cycle with appropriate logging for debugging flaky tests. The const declaration for newEnText is correct.


30-55: Clarify or remove the credentials-less login call.

Line 33 calls run(['login', '-l']) without any credentials, yet the subsequent pull command already provides --api-key pak explicitly. Other login calls in the codebase pass credentials (e.g., login pak). If this step is genuinely required for watch mode initialization, add a brief comment explaining why; otherwise, consider removing it to avoid confusion.

#!/bin/bash
# Check how login -l is used elsewhere and if it's necessary before watch
rg -n "run\(\['login'" test/e2e/ -A 2 -B 2
src/utils/pullWatch/watchHandler.ts (2)

41-68: Pending pull logic is now correctly implemented.

The fix properly:

  1. Sets pending = true and captures pendingEtag when a pull is requested during an active pull
  2. In finally, after setting pulling = false, checks pending and recursively calls with the captured etag

This ensures no events are dropped while a pull is in-flight.


137-156: Shutdown cleanup is robust and correctly handles edge cases.

The cleanup properly:

  • Guards unsubscribe?.() and wsClient.deactivate() with try-catch and comments
  • Clears both pollingTimer and debounceTimer with null guards
  • Exits cleanly with success message

@JanCizmar JanCizmar merged commit 72d18fd into main Dec 18, 2025
16 checks passed
@JanCizmar JanCizmar deleted the jancizmar/pull-watch branch December 18, 2025 09:44
@github-actions
Copy link

🎉 This PR is included in version 2.15.0-rc.1 🎉

The release is available on:

Your semantic-release bot 📦🚀

@github-actions
Copy link

🎉 This PR is included in version 2.15.0 🎉

The release is available on:

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants