diff --git a/docs/BUILDING.md b/docs/BUILDING.md index 95f93ba..9387cc2 100644 --- a/docs/BUILDING.md +++ b/docs/BUILDING.md @@ -111,6 +111,10 @@ If you prefer not to use Compose, run PostgreSQL and Keycloak yourself and point | `KEYCLOAK_ADMIN_CLIENT_ID` | `cisys-backend` | Keycloak service account used to sign users out | | `KEYCLOAK_ADMIN_CLIENT_SECRET` | `cisys` | Secret of that service account | | `STATS_CACHE_SECONDS` | `30` | How long the public front-page statistics are cached on the server | +| `PUBLIC_LOG_MAX_BYTES` | `2097152` | Maximum log bytes returned to an anonymous request | +| `PUBLIC_RATE_LIMIT_REQUESTS` | `120` | Anonymous requests per client per window; `0` disables the limiter | +| `PUBLIC_RATE_LIMIT_WINDOW_SECONDS` | `60` | Length of the anonymous rate-limit window | +| `PUBLIC_RATE_LIMIT_MAX_CLIENTS` | `10000` | Upper bound on tracked client addresses | | `DB_URL` | `jdbc:postgresql://localhost:5432/cisys` | JDBC URL | | `DB_USER` | `cisys` | Database user | | `DB_PASSWORD` | `cisys` | Database password | diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 3a44c03..5da6e5b 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -93,6 +93,16 @@ Known gaps For private repositories set an environment variable named `CISYS_GIT_` to a read-only provider token in the backend environment before starting it, then enter the variable name in the build form as an admin. The value is never stored in the database. The image build receives it as a build secret mounted for the clone step only; it is not stored in the image and later steps cannot read it. Never place secrets in workflow files or build output; each build stores its workflow file. +## Public access limits + +The public project page, its builds, logs and branches, and the public statistics are readable without signing in. + +* An anonymous log request returns at most `cisys.public.log-max-bytes` (default 2 MiB) in one response. `offset`, `limit` and `tail` select a range; the `X-Log-Size`, `X-Log-Offset` and `X-Log-Length` headers let a client continue where the response ended. The UI pages through the limit for **Download log**; when a page is rate limited it waits for `Retry-After` and continues from the same offset, so a large anonymous download completes across windows. +* Signed-in visitors are not capped on this endpoint, so the build page keeps the complete log and the full download works as before. This assumes accounts are trusted; if registration is open, an account can be used to bypass the anonymous limits. +* Anonymous requests to `/api/project-pages/**` (including branch discovery, which runs `git ls-remote`) and `/api/stats/public` are counted per client address in a fixed window: `cisys.public.rate-limit.requests` per `cisys.public.rate-limit.window-seconds` (default 120 per 60 s). Exceeding it returns `429` with `Retry-After`. Signed-in callers are not counted. +* The counter lives in this process: it resets on restart, is not shared between instances, and tracks at most `cisys.public.rate-limit.max-clients` addresses (`0` removes the bound). When that many live windows exist, one window is evicted and the new visitor admitted, so a flood of addresses cannot lock new visitors out, and purging and eviction stay constant-time per request. +* Behind a reverse proxy, set `quarkus.http.proxy.proxy-address-forwarding=true` and `quarkus.http.proxy.enable-forwarded-host=true` and have the proxy send `Forwarded` or `X-Forwarded-For`. Otherwise every visitor shares the proxy's address and therefore one bucket. + ## TLS The application is not configured for TLS. Terminate TLS in a reverse proxy, or configure the [Quarkus TLS registry](https://quarkus.io/guides/tls-registry-reference), before any shared deployment. Keycloak must also be moved off `start-dev` and onto HTTPS in that case. diff --git a/docs/manual/en/05-builds.md b/docs/manual/en/05-builds.md index 68c73a2..f7b83f6 100644 --- a/docs/manual/en/05-builds.md +++ b/docs/manual/en/05-builds.md @@ -42,7 +42,9 @@ The complete output of the build, standard output and standard error together, i * `BUILD_LOG_MAX_BYTES` (default 1 GiB) is a safety limit per build, not a normal cap. Output beyond it is read and discarded, a marker line is added, and the build is marked as truncated. The limit is exact in bytes, so if it falls inside a multi-byte character, the last character shown may be incomplete. * A build is also marked as truncated if the log could not be stored completely. Logs from before this version were capped at 120 KB; those that reached the cap are marked as truncated. -* The build page shows the first 2 MiB of a log. **Download log** saves the full log; in Chromium-based browsers it is written straight to the chosen file, in other browsers it is held in browser memory first. The backend never holds a whole log in memory. +* The build page shows the first 2 MiB of a log. For a running build, **Refresh** appends only the new output instead of downloading the log from the start; once 2 MiB are shown, it switches to the latest 2 MiB so new output stays visible. **Download log** saves the full log; in Chromium-based browsers it is written straight to the chosen file, in other browsers it is held in browser memory first. The backend never holds a whole log in memory. +* A visitor who is not signed in can read at most `PUBLIC_LOG_MAX_BYTES` (2 MiB by default) of a log in one response. The page requests the log in chunks and retries a rate-limited page after `Retry-After` from the same offset, so **Download log** can save a log larger than the limit, even across rate-limit windows. Signed-in users, including the owner of a private project, are not capped. +* Public pages pause their refresh while the tab is hidden and refresh less often when no build is running. Anonymous requests to a public page, its branches, its logs and the public statistics are also rate limited; a client that sends too many requests is told to wait and retry. ## Pinned builds diff --git a/src/backend/main/java/ai/mnemosyne/cisys/BuildLogs.java b/src/backend/main/java/ai/mnemosyne/cisys/BuildLogs.java index 6d8ca39..a54c457 100644 --- a/src/backend/main/java/ai/mnemosyne/cisys/BuildLogs.java +++ b/src/backend/main/java/ai/mnemosyne/cisys/BuildLogs.java @@ -87,17 +87,70 @@ public Throwable appendLine(long buildId, String text) { } public void streamTo(long buildId, OutputStream out) throws IOException { + streamTo(buildId, out, 0, -1); + } + + /** + * Streams the stored bytes from {@code offset}, at most {@code limit} of them. A negative + * limit means no limit. Used by the log endpoints to support partial and tail reads. + */ + public void streamTo(long buildId, OutputStream out, long offset, long limit) throws IOException { + if (offset < 0) throw new IllegalArgumentException("offset must not be negative"); + if (limit == 0) return; Long oid = logOid(buildId); if (oid == null) return; - for (long offset = 0; ; ) { - byte[] part = read(oid, offset, READ_BYTES); - if (part.length > 0) { - out.write(part); - out.flush(); - offset += part.length; + long written = 0; + for (long at = offset; limit < 0 || written < limit; ) { + int want = (int) Math.min(READ_BYTES, limit < 0 ? READ_BYTES : limit - written); + byte[] part = read(oid, at, want); + if (part.length == 0) return; + out.write(part); + out.flush(); + at += part.length; + written += part.length; + } + } + + /** Total number of bytes stored for a build, or zero when it has no log yet. */ + public long size(long buildId) { + Long oid = logOid(buildId); + if (oid == null) return 0; + return QuarkusTransaction.requiringNew().call(() -> { + try (Connection c = dataSource.getConnection()) { + LargeObject lo = largeObjects(c).open(oid, LargeObjectManager.READ); + try { + return lo.size64(); + } finally { + lo.close(); + } } - if (part.length < READ_BYTES) return; + }); + } + + /** A resolved byte range of one log. */ + public record Range(long offset, long length) {} + + /** + * Resolves the optional {@code offset}, {@code limit} and {@code tail} query values against + * the stored size. {@code tail} wins over {@code offset}; {@code maxBytes} clamps the result + * when it is not negative. A null limit means "everything that remains". A {@code tail} larger + * than {@code maxBytes} is reduced to the maximum, so the caller still gets the end of the log. + */ + public static Range range(long size, Long offset, Long limit, Long tail, long maxBytes) { + if (offset != null && offset < 0) throw new IllegalArgumentException("offset must not be negative"); + if (limit != null && limit < 0) throw new IllegalArgumentException("limit must not be negative"); + if (tail != null && tail < 0) throw new IllegalArgumentException("tail must not be negative"); + long start; + if (tail != null) { + long wanted = maxBytes >= 0 ? Math.min(tail, maxBytes) : tail; + start = Math.max(0, size - wanted); + } else { + start = offset == null ? 0 : offset; } + long available = Math.max(0, size - start); + long length = limit == null ? available : Math.min(limit, available); + if (maxBytes >= 0) length = Math.min(length, maxBytes); + return new Range(start, length); } /** For tests. */ diff --git a/src/backend/main/java/ai/mnemosyne/cisys/BuildResource.java b/src/backend/main/java/ai/mnemosyne/cisys/BuildResource.java index 6e06bdb..5f6ef20 100644 --- a/src/backend/main/java/ai/mnemosyne/cisys/BuildResource.java +++ b/src/backend/main/java/ai/mnemosyne/cisys/BuildResource.java @@ -14,7 +14,7 @@ import jakarta.transaction.Transactional; import jakarta.ws.rs.*; import jakarta.ws.rs.core.MediaType; -import jakarta.ws.rs.core.StreamingOutput; +import jakarta.ws.rs.core.Response; import org.eclipse.microprofile.jwt.JsonWebToken; import java.time.Instant; import java.util.List; @@ -57,10 +57,17 @@ private Project visible(Long id) { @GET @Path("/{buildId}") public BuildResponse detail(@PathParam("projectId") Long id,@PathParam("buildId") Long buildId) { visible(id); Build b=Build.findById(buildId);if(b==null || !id.equals(b.projectId))throw new NotFoundException();return response(b,true); } - /** A running build returns what is stored so far. */ - @GET @Path("/{buildId}/log") @Produces("text/plain; charset=UTF-8") public StreamingOutput log(@PathParam("projectId") Long id,@PathParam("buildId") Long buildId) { + /** + * A running build returns what is stored so far. Authenticated callers get the whole log by + * default; {@code offset}, {@code limit} and {@code tail} allow partial reads without a cap. + */ + @GET @Path("/{buildId}/log") @Produces("text/plain; charset=UTF-8") public Response log(@PathParam("projectId") Long id,@PathParam("buildId") Long buildId, + @QueryParam("offset") Long offset,@QueryParam("limit") Long limit,@QueryParam("tail") Long tail) { detail(id,buildId); - return out->logs.streamTo(buildId,out); + long size=logs.size(buildId); + BuildLogs.Range range; + try{range=BuildLogs.range(size,offset,limit,tail,-1);}catch(IllegalArgumentException e){throw new BadRequestException(e.getMessage());} + return LogResponses.stream(logs,buildId,size,range); } @PUT @Path("/{buildId}/pin") @Transactional public BuildResponse pin(@PathParam("projectId") Long id,@PathParam("buildId") Long buildId) { return setPinned(id,buildId,true); diff --git a/src/backend/main/java/ai/mnemosyne/cisys/LogResponses.java b/src/backend/main/java/ai/mnemosyne/cisys/LogResponses.java new file mode 100644 index 0000000..1d0283d --- /dev/null +++ b/src/backend/main/java/ai/mnemosyne/cisys/LogResponses.java @@ -0,0 +1,30 @@ +/* + * Eclipse Public License - v 2.0 + * + * THE ACCOMPANYING PROGRAM IS PROVIDED UNDER THE TERMS OF THIS ECLIPSE + * PUBLIC LICENSE ("AGREEMENT"). ANY USE, REPRODUCTION OR DISTRIBUTION + * OF THE PROGRAM CONSTITUTES RECIPIENT'S ACCEPTANCE OF THIS AGREEMENT. + */ + +package ai.mnemosyne.cisys; + +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.StreamingOutput; + +/** The response shape shared by the authenticated and public build-log endpoints. */ +final class LogResponses { + private LogResponses() {} + + /** + * Streams the resolved range as plain text. The headers let a client continue from the end of + * this response without downloading the whole log again. + */ + static Response stream(BuildLogs logs, long buildId, long size, BuildLogs.Range range) { + return Response.ok((StreamingOutput) out -> logs.streamTo(buildId, out, range.offset(), range.length())) + .type("text/plain; charset=UTF-8") + .header("X-Log-Size", size) + .header("X-Log-Offset", range.offset()) + .header("X-Log-Length", range.length()) + .build(); + } +} diff --git a/src/backend/main/java/ai/mnemosyne/cisys/ProjectBranchResource.java b/src/backend/main/java/ai/mnemosyne/cisys/ProjectBranchResource.java index 5c9b7c4..bc06a99 100644 --- a/src/backend/main/java/ai/mnemosyne/cisys/ProjectBranchResource.java +++ b/src/backend/main/java/ai/mnemosyne/cisys/ProjectBranchResource.java @@ -29,6 +29,7 @@ @Path("/api/project-pages/{name}/branches") @Produces(MediaType.APPLICATION_JSON) @PermitAll +@PublicRateLimited public class ProjectBranchResource { @Inject ProjectAccess access; @Inject GitRepositoryService repositories; diff --git a/src/backend/main/java/ai/mnemosyne/cisys/ProjectPageResource.java b/src/backend/main/java/ai/mnemosyne/cisys/ProjectPageResource.java index 45cf30d..849fb22 100644 --- a/src/backend/main/java/ai/mnemosyne/cisys/ProjectPageResource.java +++ b/src/backend/main/java/ai/mnemosyne/cisys/ProjectPageResource.java @@ -8,8 +8,10 @@ package ai.mnemosyne.cisys; +import io.quarkus.security.identity.SecurityIdentity; import jakarta.annotation.security.PermitAll; import jakarta.inject.Inject; +import jakarta.ws.rs.BadRequestException; import jakarta.ws.rs.GET; import jakarta.ws.rs.NotFoundException; import jakarta.ws.rs.Path; @@ -17,9 +19,10 @@ import jakarta.ws.rs.Produces; import jakarta.ws.rs.QueryParam; import jakarta.ws.rs.core.MediaType; -import jakarta.ws.rs.core.StreamingOutput; +import jakarta.ws.rs.core.Response; import java.time.Instant; import java.util.List; +import org.eclipse.microprofile.config.inject.ConfigProperty; /** * The page of a single project, addressed by its name (for example /cisys/ in the UI). @@ -30,9 +33,13 @@ @Path("/api/project-pages/{name}") @Produces(MediaType.APPLICATION_JSON) @PermitAll +@PublicRateLimited public class ProjectPageResource { @Inject ProjectAccess access; @Inject BuildLogs logs; + @Inject SecurityIdentity identity; + /** Anonymous callers may read at most this many bytes of one log in one response. */ + @ConfigProperty(name = "cisys.public.log-max-bytes", defaultValue = "2097152") long publicLogMaxBytes; public record ProjectInfo(Long id, String name, String repositoryUrl, boolean privateRepository) {} /** Leaves out who requested the build and any credential reference, as the page may be anonymous. */ @@ -72,13 +79,30 @@ private static PageBuild pageBuild(Build b, boolean canPin, boolean withWorkflow withWorkflow ? b.workflow : null); } + /** + * Streams a public build log. Anonymous callers are capped at {@code cisys.public.log-max-bytes} + * per response; use {@code offset}, {@code limit} or {@code tail} to page through a larger log. + * The headers expose the total size and the returned range so a client can continue. + */ @GET @Path("/builds/{buildId}/log") @Produces("text/plain; charset=UTF-8") - public StreamingOutput log(@PathParam("name") String name, @PathParam("buildId") Long buildId) { + public Response log(@PathParam("name") String name, @PathParam("buildId") Long buildId, + @QueryParam("offset") Long offset, @QueryParam("limit") Long limit, + @QueryParam("tail") Long tail) { Project p = access.visible(name); Build b = Build.findById(buildId); if (b == null || !p.id.equals(b.projectId)) throw new NotFoundException(); - return out -> logs.streamTo(buildId, out); + long size = logs.size(buildId); + // The size and range are a snapshot; a running build may append while this response streams. + // Signed-in visitors, including the owner on a private project, still get the whole log. + long maxBytes = identity.isAnonymous() ? publicLogMaxBytes : -1; + BuildLogs.Range range; + try { + range = BuildLogs.range(size, offset, limit, tail, maxBytes); + } catch (IllegalArgumentException e) { + throw new BadRequestException(e.getMessage()); + } + return LogResponses.stream(logs, buildId, size, range); } } diff --git a/src/backend/main/java/ai/mnemosyne/cisys/PublicRateLimitFilter.java b/src/backend/main/java/ai/mnemosyne/cisys/PublicRateLimitFilter.java new file mode 100644 index 0000000..7b20a17 --- /dev/null +++ b/src/backend/main/java/ai/mnemosyne/cisys/PublicRateLimitFilter.java @@ -0,0 +1,59 @@ +/* + * Eclipse Public License - v 2.0 + * + * THE ACCOMPANYING PROGRAM IS PROVIDED UNDER THE TERMS OF THIS ECLIPSE + * PUBLIC LICENSE ("AGREEMENT"). ANY USE, REPRODUCTION OR DISTRIBUTION + * OF THE PROGRAM CONSTITUTES RECIPIENT'S ACCEPTANCE OF THIS AGREEMENT. + */ + +package ai.mnemosyne.cisys; + +import io.quarkus.security.identity.SecurityIdentity; +import io.vertx.core.http.HttpServerRequest; +import jakarta.annotation.Priority; +import jakarta.inject.Inject; +import jakarta.ws.rs.Priorities; +import jakarta.ws.rs.container.ContainerRequestContext; +import jakarta.ws.rs.container.ContainerRequestFilter; +import jakarta.ws.rs.core.Context; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.ext.Provider; +import java.util.Map; + +/** + * Applies {@link PublicRateLimiter} to the endpoints marked with {@link PublicRateLimited}. + * Signed-in callers are never counted, so the authenticated build pages keep working during a + * burst of anonymous traffic. The client is identified by the socket address; behind a reverse + * proxy configure Quarkus to use the forwarded address, otherwise every visitor shares one bucket. + */ +@Provider +@PublicRateLimited +@Priority(Priorities.AUTHORIZATION) +public class PublicRateLimitFilter implements ContainerRequestFilter { + @Inject SecurityIdentity identity; + @Inject PublicRateLimiter limiter; + @Context HttpServerRequest request; + + @Override + public void filter(ContainerRequestContext context) { + // CORS preflight requests are cheap and must not consume a visitor's budget. + if (context.getMethod().equals("OPTIONS")) return; + if (!identity.isAnonymous()) return; + long retryAfter = limiter.acquire(clientAddress(), System.currentTimeMillis()); + if (retryAfter == 0) return; + context.abortWith(Response.status(429) + .type(MediaType.APPLICATION_JSON) + .header("Retry-After", retryAfter) + .entity(Map.of("error", "Too many requests. Try again later.")) + .build()); + } + + private String clientAddress() { + try { + return request.remoteAddress() == null ? "unknown" : request.remoteAddress().hostAddress(); + } catch (RuntimeException e) { + return "unknown"; + } + } +} diff --git a/src/backend/main/java/ai/mnemosyne/cisys/PublicRateLimited.java b/src/backend/main/java/ai/mnemosyne/cisys/PublicRateLimited.java new file mode 100644 index 0000000..b0f0fa6 --- /dev/null +++ b/src/backend/main/java/ai/mnemosyne/cisys/PublicRateLimited.java @@ -0,0 +1,21 @@ +/* + * Eclipse Public License - v 2.0 + * + * THE ACCOMPANYING PROGRAM IS PROVIDED UNDER THE TERMS OF THIS ECLIPSE + * PUBLIC LICENSE ("AGREEMENT"). ANY USE, REPRODUCTION OR DISTRIBUTION + * OF THE PROGRAM CONSTITUTES RECIPIENT'S ACCEPTANCE OF THIS AGREEMENT. + */ + +package ai.mnemosyne.cisys; + +import jakarta.ws.rs.NameBinding; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** Marks the anonymous endpoints that {@link PublicRateLimitFilter} rate limits. */ +@NameBinding +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.TYPE, ElementType.METHOD}) +public @interface PublicRateLimited {} diff --git a/src/backend/main/java/ai/mnemosyne/cisys/PublicRateLimiter.java b/src/backend/main/java/ai/mnemosyne/cisys/PublicRateLimiter.java new file mode 100644 index 0000000..652f285 --- /dev/null +++ b/src/backend/main/java/ai/mnemosyne/cisys/PublicRateLimiter.java @@ -0,0 +1,76 @@ +/* + * Eclipse Public License - v 2.0 + * + * THE ACCOMPANYING PROGRAM IS PROVIDED UNDER THE TERMS OF THIS ECLIPSE + * PUBLIC LICENSE ("AGREEMENT"). ANY USE, REPRODUCTION OR DISTRIBUTION + * OF THE PROGRAM CONSTITUTES RECIPIENT'S ACCEPTANCE OF THIS AGREEMENT. + */ + +package ai.mnemosyne.cisys; + +import jakarta.enterprise.context.ApplicationScoped; +import java.util.Iterator; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; +import org.eclipse.microprofile.config.inject.ConfigProperty; + +/** + * A small per-client fixed-window counter for the anonymous endpoints. It lives in this process, + * so it resets on restart and is not shared between instances; the runner is documented as a + * single-instance deployment. Expired windows are purged when the map reaches {@code max-clients}; + * if it is still full, one live window is evicted so a new client is never locked out and the + * memory a flood of addresses can use stays bounded. Expired-window purging is throttled and the + * eviction itself is constant time, so a full map cannot turn ordinary traffic into a scan of + * every entry on each request. + */ +@ApplicationScoped +public class PublicRateLimiter { + @ConfigProperty(name = "cisys.public.rate-limit.requests", defaultValue = "120") int requests; + @ConfigProperty(name = "cisys.public.rate-limit.window-seconds", defaultValue = "60") int windowSeconds; + @ConfigProperty(name = "cisys.public.rate-limit.max-clients", defaultValue = "10000") int maxClients; + + private static final long PURGE_INTERVAL_MILLIS = 1_000L; + + private record Window(long start, AtomicInteger count) {} + private final ConcurrentHashMap windows = new ConcurrentHashMap<>(); + private volatile long lastPurge; + + /** + * Counts one request. Returns zero when it is allowed, otherwise the number of seconds the + * client should wait before retrying. A non-positive request limit disables the limiter. + */ + long acquire(String client, long nowMillis) { + if (requests <= 0) return 0; + long windowMillis = Math.max(1, windowSeconds) * 1000L; + purge(nowMillis, windowMillis); + if (maxClients > 0 && windows.size() >= maxClients && !windows.containsKey(client)) { + // The map is full of live windows. Evict one and admit the new client: a client that + // rotates addresses can bypass the limiter either way, so rejecting new clients would + // only let a flood lock legitimate visitors out. Evicting the first entry the iterator + // returns keeps this O(1); "oldest" is not important for a fail-open limiter. + Iterator candidates = windows.keySet().iterator(); + if (candidates.hasNext()) windows.remove(candidates.next()); + } + Window window = windows.compute(client, (key, current) -> + current == null || nowMillis - current.start() >= windowMillis + ? new Window(nowMillis, new AtomicInteger()) + : current); + if (window.count().incrementAndGet() <= requests) return 0; + long remaining = window.start() + windowMillis - nowMillis; + return Math.max(1, (remaining + 999) / 1000); + } + + /** For tests. */ + void reset() { + windows.clear(); + lastPurge = 0; + } + + private void purge(long now, long windowMillis) { + if (maxClients <= 0 || windows.size() < maxClients) return; + // A full map is checked on every request, so do the O(n) sweep at most once a second. + if (now - lastPurge < PURGE_INTERVAL_MILLIS) return; + lastPurge = now; + windows.entrySet().removeIf(entry -> now - entry.getValue().start() >= windowMillis); + } +} diff --git a/src/backend/main/java/ai/mnemosyne/cisys/StatsResource.java b/src/backend/main/java/ai/mnemosyne/cisys/StatsResource.java index 024d501..10cb96f 100644 --- a/src/backend/main/java/ai/mnemosyne/cisys/StatsResource.java +++ b/src/backend/main/java/ai/mnemosyne/cisys/StatsResource.java @@ -38,7 +38,7 @@ public record PublicStats(long projects, long builds, long passed, long failed, private record Cached(PublicStats stats, Instant expires) {} private volatile Cached cached; - @GET @Path("/public") @PermitAll + @GET @Path("/public") @PermitAll @PublicRateLimited public Response publicStats() { Cached current = cached; if (current == null || !Instant.now().isBefore(current.expires())) { diff --git a/src/backend/main/resources/application.properties b/src/backend/main/resources/application.properties index 29c32d2..b6ea70d 100644 --- a/src/backend/main/resources/application.properties +++ b/src/backend/main/resources/application.properties @@ -1,6 +1,8 @@ quarkus.http.port=8080 quarkus.http.cors.enabled=true quarkus.http.cors.origins=http://localhost:5173,http://localhost:8180 +# Let the browser read the log-range and rate-limit headers on cross-origin requests. +quarkus.http.cors.exposed-headers=X-Log-Size,X-Log-Offset,X-Log-Length,Retry-After quarkus.oidc.auth-server-url=${KEYCLOAK_URL:http://localhost:8180}/realms/cisys quarkus.oidc.client-id=cisys-ui quarkus.oidc.application-type=service @@ -35,6 +37,13 @@ cisys.keycloak.admin-client-secret=${KEYCLOAK_ADMIN_CLIENT_SECRET:cisys} # How long the public front-page statistics are cached on the server, in seconds. cisys.stats.cache-seconds=${STATS_CACHE_SECONDS:30} +# Anonymous access limits: bytes of one build log per response, and requests per client per window. +# A request limit of zero disables the rate limiter. +cisys.public.log-max-bytes=${PUBLIC_LOG_MAX_BYTES:2097152} +cisys.public.rate-limit.requests=${PUBLIC_RATE_LIMIT_REQUESTS:120} +cisys.public.rate-limit.window-seconds=${PUBLIC_RATE_LIMIT_WINDOW_SECONDS:60} +cisys.public.rate-limit.max-clients=${PUBLIC_RATE_LIMIT_MAX_CLIENTS:10000} + # Safety limit for one build's stored output, in bytes; beyond it the build is marked truncated. cisys.build.log-max-bytes=${BUILD_LOG_MAX_BYTES:1073741824} diff --git a/src/backend/test/java/ai/mnemosyne/cisys/BuildLogTest.java b/src/backend/test/java/ai/mnemosyne/cisys/BuildLogTest.java index d03a0bd..e456374 100644 --- a/src/backend/test/java/ai/mnemosyne/cisys/BuildLogTest.java +++ b/src/backend/test/java/ai/mnemosyne/cisys/BuildLogTest.java @@ -89,8 +89,37 @@ static final class Sink implements BuildRunner.LogSink { } @Test void buildWithoutLogStreamsNothing()throws Exception{ long id=newBuild();assertNull(logs.logOid(id)); + assertEquals(0,logs.size(id)); ByteArrayOutputStream out=new ByteArrayOutputStream();logs.streamTo(id,out);assertEquals(0,out.size()); } + @Test void sizeAndPartialReadsUseStoredBytes()throws Exception{ + long id=newBuild();try(var w=logs.open(id)){w.line("0123456789");} + assertEquals(11,logs.size(id)); + ByteArrayOutputStream out=new ByteArrayOutputStream(); + logs.streamTo(id,out,2,4); + assertEquals("2345",out.toString(StandardCharsets.UTF_8)); + } + @Test void offsetPastTheEndStreamsNothing()throws Exception{ + long id=newBuild();try(var w=logs.open(id)){w.line("abc");} + ByteArrayOutputStream out=new ByteArrayOutputStream(); + logs.streamTo(id,out,100,10); + assertEquals(0,out.size()); + } + @Test void rangeResolvesOffsetLimitTailAndCap(){ + assertEquals(new BuildLogs.Range(10,20),BuildLogs.range(100,10L,20L,null,50)); + assertEquals(new BuildLogs.Range(70,30),BuildLogs.range(100,null,null,30L,50)); + assertEquals(new BuildLogs.Range(0,50),BuildLogs.range(100,null,null,null,50)); + assertEquals(new BuildLogs.Range(90,10),BuildLogs.range(100,90L,null,null,50)); + assertEquals(new BuildLogs.Range(0,50),BuildLogs.range(100,null,200L,null,50)); + assertEquals(new BuildLogs.Range(0,0),BuildLogs.range(0,null,null,null,50)); + assertEquals(new BuildLogs.Range(0,100),BuildLogs.range(100,null,null,null,-1)); + assertEquals(new BuildLogs.Range(50,50),BuildLogs.range(100,null,null,80L,50)); + } + @Test void rangeRejectsNegativeValues(){ + assertThrows(IllegalArgumentException.class,()->BuildLogs.range(100,-1L,null,null,-1)); + assertThrows(IllegalArgumentException.class,()->BuildLogs.range(100,null,-1L,null,-1)); + assertThrows(IllegalArgumentException.class,()->BuildLogs.range(100,null,null,-1L,-1)); + } @Test void periodicFlushStoresQuietOutput()throws Exception{ long id=newBuild();logs.flushInterval(200); try(var w=logs.open(id)){ diff --git a/src/backend/test/java/ai/mnemosyne/cisys/BuildResourceTest.java b/src/backend/test/java/ai/mnemosyne/cisys/BuildResourceTest.java index 73f44ae..0299ae6 100644 --- a/src/backend/test/java/ai/mnemosyne/cisys/BuildResourceTest.java +++ b/src/backend/test/java/ai/mnemosyne/cisys/BuildResourceTest.java @@ -111,6 +111,11 @@ void buildHistoryHidesCredentialReferenceAndSupportsLegacyBuilds() { @Test void processOutputIsBounded()throws Exception{var r=BuildRunner.command(List.of("sh","-c","head -c 500000 /dev/zero"),10,1024);assertEquals(0,r.code());assertTrue(r.output().endsWith("[output truncated]\n"));assertTrue(r.output().length()<1100);} @Test @TestSecurity(user="member",roles="member") @JwtSecurity(claims=@Claim(key="sub",value="other-id")) void listOmitsOutput(){Long id=project(false);build(id,"PASSED","secret-log-text");given().get("/api/projects/"+id+"/builds").then().statusCode(200).body(not(containsString("secret-log-text"))).body(not(containsString("\"output\""))).body("[0].branch",equalTo("main")).body("[0].image",equalTo("alpine:3.23")).body("[0].exitCode",equalTo(0)).body("[0].logTruncated",equalTo(false));} @Test @TestSecurity(user="member",roles="member") @JwtSecurity(claims=@Claim(key="sub",value="other-id")) void logStreamsAppendedParts(){Long id=project(false);Long b=build(id,"PASSED","one\n","two\n","é\n");given().get("/api/projects/"+id+"/builds/"+b+"/log").then().statusCode(200).contentType(startsWith("text/plain")).body(equalTo("one\ntwo\né\n"));} + @Test @TestSecurity(user="member",roles="member") @JwtSecurity(claims=@Claim(key="sub",value="other-id")) void logSupportsOffsetLimitAndTail(){Long id=project(false);Long b=build(id,"PASSED","0123456789");String path="/api/projects/"+id+"/builds/"+b+"/log"; + given().queryParam("offset",2).queryParam("limit",3).get(path).then().statusCode(200).header("X-Log-Size","10").header("X-Log-Offset","2").header("X-Log-Length","3").body(equalTo("234")); + given().queryParam("tail",4).get(path).then().statusCode(200).header("X-Log-Offset","6").body(equalTo("6789")); + given().queryParam("offset",-1).get(path).then().statusCode(400); + } @Test @TestSecurity(user="member",roles="member") @JwtSecurity(claims=@Claim(key="sub",value="other-id")) void privateLogIsHidden(){Long id=project(true);Long b=build(id,"PASSED","hidden");given().get("/api/projects/"+id+"/builds/"+b+"/log").then().statusCode(404);} @Test @TestSecurity(user="member",roles="member") @JwtSecurity(claims=@Claim(key="sub",value="other-id")) void logOfBuildInOtherProjectIs404(){Long id=project(false),other=project(false);Long b=build(other,"PASSED","elsewhere");given().get("/api/projects/"+id+"/builds/"+b+"/log").then().statusCode(404);} @Test void recoverKeepsPartialLog(){Long id=project(false);Long b=build(id,"RUNNING","partial\n");runner.recover(null);Build after=QuarkusTransaction.requiringNew().call(()->Build.findById(b));assertEquals(Build.Status.FAILED,after.status);assertNotNull(after.finishedAt);assertEquals("partial\nServer restarted during build\n",logs.readAll(b));} diff --git a/src/backend/test/java/ai/mnemosyne/cisys/ProjectPageResourceTest.java b/src/backend/test/java/ai/mnemosyne/cisys/ProjectPageResourceTest.java index ee597c7..2944dd5 100644 --- a/src/backend/test/java/ai/mnemosyne/cisys/ProjectPageResourceTest.java +++ b/src/backend/test/java/ai/mnemosyne/cisys/ProjectPageResourceTest.java @@ -23,6 +23,7 @@ @QuarkusTest class ProjectPageResourceTest { @Inject BuildLogs logs; + @Inject ProjectPageResource pages; record Seeded(String name, long buildId) {} @@ -32,6 +33,10 @@ private String project(boolean priv) { } private Seeded seed(boolean priv) { + return seed(priv, "hello"); + } + + private Seeded seed(boolean priv, String logText) { Seeded seeded = QuarkusTransaction.requiringNew().call(() -> { String n = UUID.randomUUID().toString(); Project p = new Project(); @@ -49,7 +54,7 @@ private Seeded seed(boolean priv) { b.persist(); return new Seeded(p.name, b.id); }); - byte[] log = "hello".getBytes(StandardCharsets.UTF_8); + byte[] log = logText.getBytes(StandardCharsets.UTF_8); try (var w = logs.open(seeded.buildId())) { w.append(log, 0, log.length); } return seeded; } @@ -169,6 +174,65 @@ void anonymousReadsPublicBuildLog() { .contentType(startsWith("text/plain")).body(equalTo("hello")); } + @Test + void anonymousLogReadIsCappedAndCanContinue() { + Seeded s = seed(false, "0123456789"); + ProjectPageResource resource = io.quarkus.arc.ClientProxy.unwrap(pages); + long original = resource.publicLogMaxBytes; + resource.publicLogMaxBytes = 5; + try { + String url = "/api/project-pages/" + s.name() + "/builds/" + s.buildId() + "/log"; + given().get(url).then().statusCode(200) + .header("X-Log-Size", "10") + .header("X-Log-Offset", "0").header("X-Log-Length", "5") + .body(equalTo("01234")); + given().queryParam("offset", 5).get(url).then().statusCode(200) + .header("X-Log-Offset", "5").header("X-Log-Length", "5") + .body(equalTo("56789")); + // A tail larger than the cap still returns the end of the log, not a chunk from the middle. + given().queryParam("tail", 50).get(url).then().statusCode(200) + .header("X-Log-Offset", "5").header("X-Log-Length", "5") + .body(equalTo("56789")); + } finally { + resource.publicLogMaxBytes = original; + } + } + + @Test + void anonymousLogSupportsOffsetLimitAndTail() { + Seeded s = seed(false, "0123456789"); + String url = "/api/project-pages/" + s.name() + "/builds/" + s.buildId() + "/log"; + given().queryParam("offset", 2).queryParam("limit", 3).get(url).then().statusCode(200) + .header("X-Log-Offset", "2").header("X-Log-Length", "3").body(equalTo("234")); + given().queryParam("tail", 4).get(url).then().statusCode(200) + .header("X-Log-Offset", "6").header("X-Log-Length", "4").body(equalTo("6789")); + } + + @Test + void negativeLogRangeIsRejected() { + Seeded s = seed(false); + String url = "/api/project-pages/" + s.name() + "/builds/" + s.buildId() + "/log"; + given().queryParam("offset", -1).get(url).then().statusCode(400); + given().queryParam("limit", -1).get(url).then().statusCode(400); + given().queryParam("tail", -1).get(url).then().statusCode(400); + } + + @Test + @TestSecurity(user = "lead", roles = "lead") + @JwtSecurity(claims = @Claim(key = "sub", value = "owner-id")) + void signedInLogReadIsNotCapped() { + Seeded s = seed(true, "0123456789"); + ProjectPageResource resource = io.quarkus.arc.ClientProxy.unwrap(pages); + long original = resource.publicLogMaxBytes; + resource.publicLogMaxBytes = 5; + try { + given().get("/api/project-pages/" + s.name() + "/builds/" + s.buildId() + "/log").then().statusCode(200) + .header("X-Log-Size", "10").body(equalTo("0123456789")); + } finally { + resource.publicLogMaxBytes = original; + } + } + @Test void anonymousCannotReadPrivateBuildLog() { Seeded s = seed(true); diff --git a/src/backend/test/java/ai/mnemosyne/cisys/PublicRateLimitTest.java b/src/backend/test/java/ai/mnemosyne/cisys/PublicRateLimitTest.java new file mode 100644 index 0000000..1c85b40 --- /dev/null +++ b/src/backend/test/java/ai/mnemosyne/cisys/PublicRateLimitTest.java @@ -0,0 +1,94 @@ +/* + * Eclipse Public License - v 2.0 + * + * THE ACCOMPANYING PROGRAM IS PROVIDED UNDER THE TERMS OF THIS ECLIPSE + * PUBLIC LICENSE ("AGREEMENT"). ANY USE, REPRODUCTION OR DISTRIBUTION + * OF THE PROGRAM CONSTITUTES RECIPIENT'S ACCEPTANCE OF THIS AGREEMENT. + */ + +package ai.mnemosyne.cisys; + +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.security.TestSecurity; +import jakarta.inject.Inject; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import static io.restassured.RestAssured.given; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.notNullValue; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@QuarkusTest +class PublicRateLimitTest { + @Inject PublicRateLimiter limiter; + int requests, windowSeconds, maxClients; + + @BeforeEach + void save() { + limiter = io.quarkus.arc.ClientProxy.unwrap(limiter); + requests = limiter.requests; + windowSeconds = limiter.windowSeconds; + maxClients = limiter.maxClients; + limiter.reset(); + } + + @AfterEach + void restore() { + limiter.requests = requests; + limiter.windowSeconds = windowSeconds; + limiter.maxClients = maxClients; + limiter.reset(); + } + + @Test + void anonymousRequestsAreLimited() { + limiter.requests = 2; + limiter.windowSeconds = 60; + given().get("/api/stats/public").then().statusCode(200); + given().get("/api/stats/public").then().statusCode(200); + given().get("/api/stats/public").then().statusCode(429) + .header("Retry-After", notNullValue()) + .body("error", equalTo("Too many requests. Try again later.")); + } + + @Test + void projectPagesShareTheLimit() { + limiter.requests = 1; + limiter.windowSeconds = 60; + given().get("/api/project-pages/does-not-exist").then().statusCode(404); + given().get("/api/project-pages/does-not-exist").then().statusCode(429); + } + + @Test + void branchListingSharesTheLimit() { + limiter.requests = 1; + limiter.windowSeconds = 60; + given().get("/api/project-pages/does-not-exist/branches").then().statusCode(404); + given().get("/api/project-pages/does-not-exist/branches").then().statusCode(429); + } + + @Test + @TestSecurity(user = "member", roles = "member") + void signedInRequestsAreNotLimited() { + limiter.requests = 1; + limiter.windowSeconds = 60; + for (int i = 0; i < 5; i++) given().get("/api/stats/public").then().statusCode(200); + } + + @Test + void aFullClientMapEvictsAWindowInsteadOfRejectingANewClient() { + limiter.requests = 1; + limiter.windowSeconds = 60; + limiter.maxClients = 2; + long now = 1_000L; + assertEquals(0, limiter.acquire("first", now)); + assertEquals(0, limiter.acquire("second", now + 1)); + // A known client is still limited while its window is live. + assertTrue(limiter.acquire("second", now + 2) > 0); + // The map is full, so a live window gives way to a new visitor instead of locking it out. + assertEquals(0, limiter.acquire("third", now + 3)); + assertTrue(limiter.acquire("third", now + 4) > 0); + } +} diff --git a/src/backend/test/resources/application.properties b/src/backend/test/resources/application.properties index d9fa038..ef1dac2 100644 --- a/src/backend/test/resources/application.properties +++ b/src/backend/test/resources/application.properties @@ -9,3 +9,6 @@ cisys.stats.cache-seconds=0 # Retention is run directly by RetentionTest, never on a schedule. quarkus.scheduler.enabled=false + +# Tests share 127.0.0.1, so keep the anonymous limiter out of the way; PublicRateLimitTest lowers it. +cisys.public.rate-limit.requests=100000 diff --git a/src/frontend/package-lock.json b/src/frontend/package-lock.json index 112c82d..9c21b61 100644 --- a/src/frontend/package-lock.json +++ b/src/frontend/package-lock.json @@ -65,6 +65,7 @@ }, "devDependencies": { "@eslint/js": "^9.39.4", + "@testing-library/react": "^16.3.3", "@types/lodash": "^4.17.24", "@types/node": "^25.5.0", "@types/react": "^19.2.14", @@ -76,11 +77,13 @@ "eslint-plugin-react-hooks": "~7.0.1", "globals": "^16.0.0", "husky": "^9.1.7", + "jsdom": "^31.0.1", "lint-staged": "^16.4.0", "prettier": "^3.8.1", "typescript": "^6.0.2", "typescript-eslint": "^8.58.0", - "vite": "^7.1.3" + "vite": "^7.1.3", + "vitest": "^5.1.0" }, "engines": { "node": ">=26" @@ -1346,6 +1349,24 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@exodus/bytes": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.16.0.tgz", + "integrity": "sha512-IcpW84uEn3N7ETtNZMlxKhfl6Pec8rUNGOTBtWbK1FKhJxIFAptZyVrvVRVBimAJxJCgc3PxepxkdWWG4DVzfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@floating-ui/core": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz", @@ -4172,6 +4193,55 @@ "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.2", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.2.tgz", + "integrity": "sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/react": { + "version": "16.3.3", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.3.tgz", + "integrity": "sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@ts-morph/common": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/@ts-morph/common/-/common-0.27.0.tgz", @@ -4219,6 +4289,14 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -4264,6 +4342,17 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/debug": { "version": "4.1.13", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", @@ -4273,6 +4362,13 @@ "@types/ms": "*" } }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -4695,6 +4791,54 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/@vitest/mocker": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.1.0.tgz", + "integrity": "sha512-5umB9/jlWcqVS6fsrFmv5Gdnpu6wAUio/lEOwAmvB7v+lFo/IW2B9FhAD0p19GCULTgMzLCdUmuCDCnAzWgppQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.31", + "@vitest/spy": "5.1.0", + "estree-walker": "^3.0.3", + "magic-string": "^1.4.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9 || ^3.0.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker/node_modules/magic-string": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.5.tgz", + "integrity": "sha512-oLLC2+YvQUfAa+vxoufE/yc1icb87M9iXIRqAoR/2EhE/8cb3SF/+Gi0c2By1B+5Vz2e2vQtUvHiu1+63Znh+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, + "node_modules/@vitest/spy": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.1.0.tgz", + "integrity": "sha512-2DPO5OMHWKrBngC+fCmTQqemNF0gidRhp3lxbBmRhW+M1XJrfgPpqNqWVevXtn2f2ufD5ZaR1B6B641aAlcX5A==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/acorn": { "version": "8.19.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.19.0.tgz", @@ -4846,6 +4990,17 @@ "node": ">=10" } }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "dequal": "^2.0.3" + } + }, "node_modules/array-buffer-byte-length": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", @@ -4995,6 +5150,16 @@ "solid-transition-group": "^0.2.3" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/ast-types": { "version": "0.16.3", "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.16.3.tgz", @@ -5071,6 +5236,33 @@ "node": ">=6.0.0" } }, + "node_modules/bcp-47": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/bcp-47/-/bcp-47-2.1.2.tgz", + "integrity": "sha512-wsv86UuNFuPnVieKLyECTT+CP1WSAHeoIg5P3xu2kA9Z+7fVPoKt7dxzSeEhtJel0AI+NW3fIW3KtTKQKUCngg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/bcp-47-match": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/bcp-47-match/-/bcp-47-match-2.0.3.tgz", + "integrity": "sha512-JtTezzbAibu8G0R9op9zb3vcWZd9JF6M0xOYGPn0fNCd7wOpRB1mU2mH9T8gaBGbAAyIIVgB2G7xG0GP98zMAQ==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/brace-expansion": { "version": "1.1.21", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", @@ -5232,6 +5424,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/chai": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz", + "integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -5592,6 +5794,20 @@ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, + "node_modules/data-urls": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-8.0.0.tgz", + "integrity": "sha512-JQUCCK+/CAGbGGx618PlFZMXH81Jtj7t73CHv9dd/zVWvxgGT2zLFRjnKb+Ng46GIwcy+f7f88qd4duqllK+WQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^17.1.2" + }, + "engines": { + "node": "^22.14.0 || >=24.0.0" + } + }, "node_modules/data-view-buffer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", @@ -5694,6 +5910,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/decode-named-character-reference": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", @@ -5872,6 +6095,14 @@ "node": ">=0.10.0" } }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/dot-prop": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-6.0.1.tgz", @@ -5973,6 +6204,19 @@ "node": ">=8" } }, + "node_modules/entities": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-9.0.0.tgz", + "integrity": "sha512-oJFNDJg3kTPwAD1cNn2+WmUq4wFoo09npc3Ydf1D7ED4smRiWrsKnaLxykrTWgyGEcusc87P5dxlAleol9kpkg==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/env-paths": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", @@ -6131,6 +6375,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", @@ -6494,6 +6745,16 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -6537,6 +6798,16 @@ "url": "https://github.com/sindresorhus/execa?sponsor=1" } }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", @@ -7215,6 +7486,19 @@ "node": ">=12.0.0" } }, + "node_modules/html-encoding-sniffer": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-7.0.0.tgz", + "integrity": "sha512-UikN5yr7xsCDAq87Or5or0PAlD3HJJOKVzM05az588WnpDJ4Ux7a2A53Qi6gofGg2/EtvF/H4hCi/TXfCW4Y6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.15.1" + }, + "engines": { + "node": "^22.13.0 || >=24.0.0" + } + }, "node_modules/html-url-attributes": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", @@ -7757,6 +8041,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/is-regex": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", @@ -8049,6 +8340,64 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "31.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-31.0.1.tgz", + "integrity": "sha512-CbhJL8ZQg3eKswxSZth4QO9tW0jPIhmR0vtSXQSbKSfWbwIT6hoRc17wdHIVTQ43KI0mdV5iTJr3281IDgVZ5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.16.0", + "bcp-47": "^2.1.2", + "bcp-47-match": "^2.0.3", + "data-urls": "^8.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^7.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.3", + "parse5": "^8.1.0", + "parse5-parser-stream": "^8.1.0", + "saxes": "^6.0.0", + "tough-cookie": "^6.0.2", + "undici": "^8.12.3", + "w3c-xmlserializer": "^6.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^17.3.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + }, + "peerDependencies": { + "@napi-rs/canvas": "^1.0.13" + }, + "peerDependenciesMeta": { + "@napi-rs/canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/jsdom/node_modules/undici": { + "version": "8.12.4", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.12.4.tgz", + "integrity": "sha512-8Mx+uNKKER2bA4AdtedkqRbwUCIJFr2nJckXRe5yrDqzePqwLP7VNcctN8/XdmDSbF7mS+jJKIszlL+vbam1VA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -8684,6 +9033,17 @@ } } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -9658,6 +10018,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/obug": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/obug/-/obug-3.0.0.tgz", + "integrity": "sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/onetime": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", @@ -9890,6 +10264,32 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parse5": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.1.1.tgz", + "integrity": "sha512-EcR/6WDcU7b2N3vQSxKALCk9lXGtUMQxXio008OEMBE0kavu/7JLfBEeFPzi43nMqVDSbEn3QCK+BMCc9zO//w==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^9.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-parser-stream": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/parse5-parser-stream/-/parse5-parser-stream-8.1.1.tgz", + "integrity": "sha512-hMcVbbUJcQmP8rURWUdYrT1flRiBiBVjq/RYGguJpDUgIXSqdYKlE0CxthmdMWoSlBAuL5bUBa73g0pttSy7Hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "parse5": "^8.1.1" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/path-browserify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", @@ -10102,6 +10502,55 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/pretty-format/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/pretty-ms": { "version": "9.3.1", "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.3.1.tgz", @@ -10858,6 +11307,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -11252,6 +11714,13 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/std-env": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz", + "integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==", + "dev": true, + "license": "MIT" + }, "node_modules/stdin-discarder": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.2.2.tgz", @@ -11607,6 +12076,16 @@ "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", "license": "MIT" }, + "node_modules/tinybench": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.3.tgz", + "integrity": "sha512-Al01C2JIBnO2CXCAPbpAl6sUiOZHeThGtHYNTa51JRw9PyWOCdr+9Ong05R4qg/Sk3+H645E8ov1h2c58ityNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/tinyexec": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", @@ -11633,6 +12112,26 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tldts": { + "version": "7.5.2", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.5.2.tgz", + "integrity": "sha512-p+JwEdxIWQx033pMvW9ULBbyC1Eg6JtWo2Mo0jbC8TVSjp2QrHFE+WKs8JpMGc3ECMsmBpU2RH/25NgX3k1ulg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.5.2" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.5.2", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.5.2.tgz", + "integrity": "sha512-8W1topazDwhMKbm2I83IPa57Ow5VUxFgLbXv42+vSILnv8FY5OHvdEY/tS6WoWeMbxkHZQhra5cmeFHriKptSQ==", + "dev": true, + "license": "MIT" + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -11645,6 +12144,32 @@ "node": ">=8.0" } }, + "node_modules/tough-cookie": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.1.0.tgz", + "integrity": "sha512-FTxc8u8Kl2tz0Lat1u8ksvcy8bwHRyEMawwTWybo51JqaC6O7SvfuhAKg+Ld6VqyIe2QRJBzY9HA7y2WyFRT2w==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-7.0.0.tgz", + "integrity": "sha512-BcDSDW+SIGWVOns1Zm6Yg91q9AEj7eaID0QDY9oIqJD1A8dscNwMcl9Sms2DeuWCNov1X0/oXanHGYQBSLwZsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": "^22.14.0 || >=24.0.0" + } + }, "node_modules/trim-lines": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", @@ -12200,6 +12725,147 @@ } } }, + "node_modules/vitest": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.1.0.tgz", + "integrity": "sha512-MzmNFnrwa8v8lF1bLxw3UJ4EREwVOVb75VqJZmUDRjJ6EluK/x9th7qTbaeDk41WxE1GxWqmTXcdsfobfH5sOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/mocker": "5.1.0", + "chai": "^6.3.0", + "es-module-lexer": "^2.3.2", + "expect-type": "^1.4.0", + "magic-string": "^1.4.3", + "obug": "^3.0.0", + "picomatch": "^4.0.7", + "std-env": "^4.3.0", + "tinybench": "^6.2.2", + "tinyexec": "^1.3.1", + "tinyglobby": "^0.2.17", + "why-is-node-running": "3.2.1" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^22.12.0 || ^24.0.0 || >=26.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "5.1.0", + "@vitest/browser-preview": "5.1.0", + "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", + "@vitest/coverage-istanbul": "5.1.0", + "@vitest/coverage-v8": "5.1.0", + "@vitest/ui": "5.1.0", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/magic-string": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.5.tgz", + "integrity": "sha512-oLLC2+YvQUfAa+vxoufE/yc1icb87M9iXIRqAoR/2EhE/8cb3SF/+Gi0c2By1B+5Vz2e2vQtUvHiu1+63Znh+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, + "node_modules/w3c-xmlserializer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-6.0.0.tgz", + "integrity": "sha512-4Nsy8K5Tr6SPDH9jhKJOHf7ChDrc1zufZTVSF7x72hwuEXBqxqk9G6cK+K2NRUtB3iELRJqjXb4JPDMBjMTl2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "17.3.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.3.0.tgz", + "integrity": "sha512-KwOAJrpf+GTgBvcBQJcP38sMF0DsQKdwtG+TxM2pW44P6E5k0hDbWrDmyuJDIF0Uef0aQq0ZoJs+5oI+k8ddhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.16.0", + "tr46": "^7.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^22.14.0 || >=24.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -12304,6 +12970,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/why-is-node-running": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-3.2.1.tgz", + "integrity": "sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==", + "dev": true, + "license": "MIT", + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=20.11" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -12379,6 +13058,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/src/frontend/package.json b/src/frontend/package.json index a939f54..2ccc8df 100644 --- a/src/frontend/package.json +++ b/src/frontend/package.json @@ -10,7 +10,8 @@ "dev": "vite --host 127.0.0.1", "build": "vite build", "lint": "eslint src", - "check": "npm run lint && npm run build", + "test": "vitest run", + "check": "npm run lint && npm run test && npm run build", "prepare": "cd ../.. && husky" }, "dependencies": { @@ -70,6 +71,8 @@ "zustand": "^5.0.12" }, "devDependencies": { + "@eslint/js": "^9.39.4", + "@testing-library/react": "^16.3.3", "@types/lodash": "^4.17.24", "@types/node": "^25.5.0", "@types/react": "^19.2.14", @@ -79,14 +82,15 @@ "eslint-config-prettier": "^10.1.8", "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "~7.0.1", + "globals": "^16.0.0", "husky": "^9.1.7", + "jsdom": "^31.0.1", "lint-staged": "^16.4.0", "prettier": "^3.8.1", "typescript": "^6.0.2", "typescript-eslint": "^8.58.0", "vite": "^7.1.3", - "globals": "^16.0.0", - "@eslint/js": "^9.39.4" + "vitest": "^5.1.0" }, "lint-staged": { "src/**/*.{ts,tsx,js,jsx}": [ diff --git a/src/frontend/src/BuildLogView.jsx b/src/frontend/src/BuildLogView.jsx index 6489b04..3409cbe 100644 --- a/src/frontend/src/BuildLogView.jsx +++ b/src/frontend/src/BuildLogView.jsx @@ -13,6 +13,47 @@ import { CardTitle } from './components/ui/card'; import { duration, startedAt } from './build-format'; const INLINE_LIMIT = 2 * 1024 * 1024; +/** A rate-limited page is retried after Retry-After; enough retries for a 1 GiB log over 2 MiB pages. */ +const MAX_RATE_LIMIT_RETRIES = 6; +const RETRY_FALLBACK_SECONDS = 5; + +function sleep(millis) { + return new Promise((resolve) => setTimeout(resolve, millis)); +} + +/** + * Reads the whole log in pages. The public endpoint caps each response, so keep requesting + * from the next offset until one comes back empty. A rate-limited page is retried from the + * same offset after Retry-After, so a large anonymous download completes across windows. + */ +export async function copyLog(api, logPath, sink, wait = sleep) { + let offset = 0; + let retries = 0; + for (;;) { + let response; + try { + response = await api(`${logPath}?offset=${offset}`, { raw: true }); + } catch (e) { + if (e.status !== 429 || retries >= MAX_RATE_LIMIT_RETRIES) throw e; + retries += 1; + const seconds = Number(e.retryAfter); + await wait((Number.isFinite(seconds) && seconds > 0 ? seconds : RETRY_FALLBACK_SECONDS) * 1000); + continue; + } + const reader = response.body.getReader(); + let received = 0; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + received += value.length; + await sink.write(value); + } + offset += received; + if (received === 0) return; + // A successful page proves the download is making progress; only bound consecutive 429s. + retries = 0; + } +} /** * The expanded details and log of one build. Used inline in the signed-in build history @@ -24,38 +65,72 @@ export function BuildLogPanel({ api, logPath, build, onRefresh, showMeta = true, const [loading, setLoading] = useState(false); const [text, setText] = useState(''); const [more, setMore] = useState(false); + const [tail, setTail] = useState(false); const [error, setError] = useState(''); const scrollRef = useRef(null); // Follow the end of a running log, unless the visitor has scrolled up to read earlier output. const stickToBottom = useRef(true); + // Bytes already shown inline; a refresh asks only for what came after them. + const offsetRef = useRef(0); + // Identifies the latest request. A status change can start a load while an earlier one is still + // pending; only the latest may append text or move the offset, so the next refresh resumes from + // the accepted response instead of skipping output. + const requestRef = useRef(0); + // Kept across appends so a character split at a page boundary is completed by the next page. + const decoderRef = useRef(new TextDecoder('utf-8')); + // Mirrors `tail` for the status-change effect, which must not restart from the beginning. + const tailRef = useRef(false); - const load = useCallback(async () => { + const load = useCallback(async ({ reset = true, running = false } = {}) => { + const request = ++requestRef.current; + const current = () => request === requestRef.current; setLoading(true); setError(''); + if (reset) { offsetRef.current = 0; decoderRef.current = new TextDecoder('utf-8'); tailRef.current = false; setTail(false); } + const startOffset = offsetRef.current; + // Once the inline view has filled up, a running build switches to the tail so new output stays visible. + const showTail = !reset && running && startOffset >= INLINE_LIMIT; + const remaining = INLINE_LIMIT - startOffset; + if (!showTail && remaining <= 0) { setMore(true); setLoaded(true); setLoading(false); return; } try { - const response = await api(logPath, { raw: true }); + const params = showTail + ? new URLSearchParams({ tail: String(INLINE_LIMIT) }) + : new URLSearchParams({ offset: String(startOffset), limit: String(remaining) }); + const response = await api(`${logPath}?${params.toString()}`, { raw: true }); + if (!current()) { await response.body?.cancel().catch(() => {}); return; } + const size = Number(response.headers.get('X-Log-Size') || 0); const reader = response.body.getReader(); - const decoder = new TextDecoder('utf-8'); - let received = 0, out = '', cut = false; + const decoder = showTail ? new TextDecoder('utf-8') : decoderRef.current; + let received = 0, out = ''; for (;;) { const { done, value } = await reader.read(); - if (done) { out += decoder.decode(); break; } - const room = INLINE_LIMIT - received; - if (value.length > room) { - // No final decode, so a character cut at the limit is dropped instead of shown as a replacement character. - out += decoder.decode(value.subarray(0, room), { stream: true }); - cut = true; - await reader.cancel(); - break; - } + if (done) break; + if (!current()) { await reader.cancel().catch(() => {}); return; } received += value.length; out += decoder.decode(value, { stream: true }); } - setText(out); setMore(cut); setLoaded(true); - } catch (e) { setError(e.message); } - finally { setLoading(false); } + if (!current()) return; + if (showTail) { + offsetRef.current = Math.max(startOffset, size); + setText(out); + tailRef.current = true; + setTail(true); + setMore(false); + } else { + // Only the accepted response moves the offset; a superseded one must not skip output. + offsetRef.current = startOffset + received; + setText((previous) => (reset ? out : previous + out)); + setMore(size > offsetRef.current); + } + setLoaded(true); + } catch (e) { if (current()) setError(e.message); } + finally { if (current()) setLoading(false); } }, [api, logPath]); - useEffect(() => { void load(); }, [load, build.status]); + useEffect(() => { + // When a build finishes while the view follows the tail, keep showing the end of the log. + if (tailRef.current) void load({ reset: false, running: true }); + else void load(); + }, [load, build.status]); useEffect(() => { if (!scrollable || build.status !== 'RUNNING' || !stickToBottom.current) return; @@ -77,19 +152,30 @@ export function BuildLogPanel({ api, logPath, build, onRefresh, showMeta = true, let handle; try { handle = await window.showSaveFilePicker({ suggestedName: name }); } catch (e) { if (e.name === 'AbortError') return; throw e; } - const response = await api(logPath, { raw: true }); - await response.body.pipeTo(await handle.createWritable()); + const writable = await handle.createWritable(); + try { + await copyLog(api, logPath, writable); + await writable.close(); + } catch (e) { + // Do not leave a truncated file behind when a page fails, for example on a 429. + await writable.abort().catch(() => {}); + throw e; + } return; } - const response = await api(logPath, { raw: true }); - const url = URL.createObjectURL(await response.blob()); + const chunks = []; + await copyLog(api, logPath, { write: async (chunk) => { chunks.push(chunk); } }); + const url = URL.createObjectURL(new Blob(chunks, { type: 'text/plain' })); const a = document.createElement('a'); a.href = url; a.download = name; a.click(); setTimeout(() => URL.revokeObjectURL(url), 10000); } catch (e) { setError(e.message); } } - async function refresh() { if (onRefresh) await onRefresh(); await load(); } + async function refresh() { + await load({ reset: false, running: build.status === 'RUNNING' }); + if (onRefresh) await onRefresh(); + } return (
@@ -114,7 +200,8 @@ export function BuildLogPanel({ api, logPath, build, onRefresh, showMeta = true,
) : loading && !loaded ?

Loading log…

:
{text}
} - {more &&

Showing the first 2 MiB; download the full log.

} + {tail ?

Showing the latest 2 MiB; download the full log.

+ : more &&

Showing the first 2 MiB; download the full log.

} ); } diff --git a/src/frontend/src/BuildLogView.test.jsx b/src/frontend/src/BuildLogView.test.jsx new file mode 100644 index 0000000..d76b8af --- /dev/null +++ b/src/frontend/src/BuildLogView.test.jsx @@ -0,0 +1,123 @@ +import React from 'react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { BuildLogPanel, copyLog } from './BuildLogView'; + +function response(text, size) { + return { + headers: { get: (name) => (name === 'X-Log-Size' ? String(size) : null) }, + body: new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(text)); + controller.close(); + }, + }), + }; +} + +function deferred() { + let resolve; + const promise = new Promise((done) => { resolve = done; }); + return { promise, resolve }; +} + +describe('BuildLogPanel', () => { + it('ignores a superseded load and resumes from the accepted offset', async () => { + const requests = []; + const api = vi.fn((url) => { + const request = deferred(); + requests.push({ url, ...request }); + return request.promise; + }); + const logPath = '/api/projects/1/builds/7/log'; + const { container, rerender } = render( + , + ); + + await waitFor(() => expect(requests).toHaveLength(1)); + + // A build status change starts a second load while the first is still pending. + rerender(); + await waitFor(() => expect(requests).toHaveLength(2)); + expect(requests[0].url).toContain('offset=0'); + expect(requests[1].url).toContain('offset=0'); + + // The later request wins, so its bytes are shown and the offset moves to 3. + requests[1].resolve(response('abc', 3)); + await waitFor(() => expect(container.querySelector('pre').textContent).toBe('abc')); + + // The earlier response arrives late and must not append its bytes or move the offset again. + requests[0].resolve(response('abc', 3)); + expect(container.querySelector('pre').textContent).toBe('abc'); + + // The next refresh asks for the accepted offset, not 6. + const refresh = await screen.findByRole('button', { name: /refresh/i }); + await waitFor(() => expect(refresh.disabled).toBe(false)); + fireEvent.click(refresh); + await waitFor(() => expect(requests).toHaveLength(3)); + expect(requests[2].url).toContain('offset=3'); + + requests[2].resolve(response('', 3)); + await waitFor(() => expect(container.querySelector('pre').textContent).toBe('abc')); + }); + + it('retries a rate-limited log page from the same offset', async () => { + const urls = []; + const replies = [ + Object.assign(new Error('Too many requests. Try again later.'), { status: 429, retryAfter: 2 }), + response('abc', 3), + response('', 3), + ]; + const api = vi.fn((url) => { + urls.push(url); + const next = replies.shift(); + return next instanceof Error ? Promise.reject(next) : Promise.resolve(next); + }); + const chunks = []; + const waits = []; + + await copyLog(api, '/log', { write: async (chunk) => { chunks.push(chunk); } }, async (millis) => { waits.push(millis); }); + + expect(urls).toEqual(['/log?offset=0', '/log?offset=0', '/log?offset=3']); + expect(new TextDecoder().decode(new Uint8Array(chunks.flatMap((chunk) => [...chunk])))).toBe('abc'); + expect(waits).toEqual([2000]); + }); + + it('resets the retry budget after a successful page', async () => { + const rateLimited = () => Object.assign( + new Error('Too many requests. Try again later.'), + { status: 429, retryAfter: 0 }, + ); + const urls = []; + // Six 429s are the per-page budget; a successful page must restore it, so a second run of six + // still completes instead of aborting with the cumulative count. + const replies = [ + ...Array.from({ length: 6 }, rateLimited), + response('abc', 3), + ...Array.from({ length: 6 }, rateLimited), + response('def', 6), + response('', 6), + ]; + const api = vi.fn((url) => { + urls.push(url); + const next = replies.shift(); + return next instanceof Error ? Promise.reject(next) : Promise.resolve(next); + }); + const chunks = []; + + await copyLog(api, '/log', { write: async (chunk) => { chunks.push(chunk); } }, async () => {}); + + expect(urls).toEqual([ + ...Array(7).fill('/log?offset=0'), + ...Array(7).fill('/log?offset=3'), + '/log?offset=6', + ]); + expect(new TextDecoder().decode(new Uint8Array(chunks.flatMap((chunk) => [...chunk])))).toBe('abcdef'); + }); + + it('does not retry a download that failed for another reason', async () => { + const api = vi.fn(() => Promise.reject(Object.assign(new Error('Server error'), { status: 500 }))); + await expect(copyLog(api, '/log', { write: async () => {} }, async () => {})).rejects.toThrow('Server error'); + expect(api).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/frontend/src/ProjectBuilds.jsx b/src/frontend/src/ProjectBuilds.jsx index 8bd8b54..b317be1 100644 --- a/src/frontend/src/ProjectBuilds.jsx +++ b/src/frontend/src/ProjectBuilds.jsx @@ -6,7 +6,7 @@ * OF THE PROGRAM CONSTITUTES RECIPIENT'S ACCEPTANCE OF THIS AGREEMENT. */ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import { GitBranch, Hammer, LockKeyhole, LockKeyholeOpen, Pin } from 'lucide-react'; import { Badge } from './components/ui/badge'; import { Card, CardContent } from './components/ui/card'; @@ -55,6 +55,8 @@ export default function ProjectBuilds({ name, load, onOpenBuild, onBuild, onNotF const [startingBranch, setStartingBranch] = useState(null); const [reload, setReload] = useState(0); const projectId = page?.project?.id; + // Latest page, so the polling loop can decide whether a build is still active without restarting. + const pageRef = useRef(undefined); // main is the default branch; older repositories still use master. There is no "all branches" view. const branchNames = branches || []; const defaultBranch = branchNames.includes('main') ? 'main' @@ -63,20 +65,34 @@ export default function ProjectBuilds({ name, load, onOpenBuild, onBuild, onNotF useEffect(() => { let active = true; + let lastFetch = 0; const branchQuery = activeBranch ? `?branch=${encodeURIComponent(activeBranch)}` : ''; const fetchPage = async () => { + lastFetch = Date.now(); try { const data = await load(`/project-pages/${encodeURIComponent(name)}${branchQuery}`); - if (active) { setPage(data); setBuildsBranch(activeBranch); setError(''); } + if (active) { pageRef.current = data; setPage(data); setBuildsBranch(activeBranch); setError(''); } } catch (e) { if (!active) return; - if (e.status === 404) onNotFound(); + if (e.status === 404) { pageRef.current = null; setPage(null); onNotFound(); } else setError(e.message || 'Unable to load the project.'); } }; void fetchPage(); - const timer = setInterval(fetchPage, 5000); - return () => { active = false; clearInterval(timer); }; + // Poll only while the tab is visible, and back off when no build is running. + const timer = setInterval(() => { + if (document.hidden) return; + const builds = pageRef.current?.builds ?? []; + const activeBuild = builds.some((b) => b.status === 'RUNNING' || b.status === 'QUEUED'); + if (activeBuild || Date.now() - lastFetch >= 30000) void fetchPage(); + }, 5000); + const onVisibility = () => { if (!document.hidden) void fetchPage(); }; + document.addEventListener('visibilitychange', onVisibility); + return () => { + active = false; + clearInterval(timer); + document.removeEventListener('visibilitychange', onVisibility); + }; }, [name, load, activeBranch, reload, onNotFound]); // Branches are loaded once per project, not on every build refresh. Public projects read the diff --git a/src/frontend/src/main.jsx b/src/frontend/src/main.jsx index f709e75..21a4717 100644 --- a/src/frontend/src/main.jsx +++ b/src/frontend/src/main.jsx @@ -35,7 +35,11 @@ const DEFAULT_BRANDING = { name: 'cisys', headerColor: '#176a4a', headingColor: async function publicJson(path, opts = {}) { const response = await fetch(API + path); - if (!response.ok) throw Object.assign(new Error(`Request failed (${response.status})`), { status: response.status }); + if (!response.ok) { + const body = await response.json().catch(() => null); + const message = body?.error || (response.status === 429 ? 'Too many requests. Try again later.' : `Request failed (${response.status})`); + throw Object.assign(new Error(message), { status: response.status, retryAfter: Number(response.headers.get('Retry-After')) || null }); + } if (opts.raw) return response; return response.json(); } @@ -54,9 +58,9 @@ async function api(path, opts = {}) { }); if (!response.ok) { const body = await response.json().catch(() => null); - if (body?.error) throw Object.assign(new Error(body.error), { status: response.status }); + if (body?.error) throw Object.assign(new Error(body.error), { status: response.status, retryAfter: Number(response.headers.get('Retry-After')) || null }); } - if (!response.ok) throw Object.assign(new Error(response.status === 429 ? 'The build queue is full. Try again later.' : `Request failed (${response.status}). Check the branch and project access.`), { status: response.status }); + if (!response.ok) throw Object.assign(new Error(response.status === 429 ? 'The build queue is full. Try again later.' : `Request failed (${response.status}). Check the branch and project access.`), { status: response.status, retryAfter: Number(response.headers.get('Retry-After')) || null }); if (opts.raw) return response; if (response.status === 204) return null; return response.json(); diff --git a/src/frontend/vitest.config.js b/src/frontend/vitest.config.js new file mode 100644 index 0000000..9609c63 --- /dev/null +++ b/src/frontend/vitest.config.js @@ -0,0 +1,11 @@ +import { fileURLToPath, URL } from 'node:url'; +import { defineConfig } from 'vitest/config'; +import react from '@vitejs/plugin-react'; + +export default defineConfig({ + plugins: [react()], + resolve: { alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) } }, + test: { + environment: 'jsdom', + }, +});