From d3c3319c32f5e0a25870f4845a0b4c065f1ada54 Mon Sep 17 00:00:00 2001
From: Omar Ibrahim
Date: Tue, 2 Jun 2026 15:22:39 -0500
Subject: [PATCH 001/101] minor(modeling-commons): load zod async
---
.../app/components/shared/SocialLink.vue | 2 +-
apps/modeling-commons-frontend/app/forms/auth.ts | 6 ++++--
.../modeling-commons-frontend/app/forms/models.ts | 8 +++++---
.../modeling-commons-frontend/app/forms/upload.ts | 15 +++++++++------
.../app/utils/file-schema.ts | 3 ++-
5 files changed, 21 insertions(+), 13 deletions(-)
diff --git a/apps/modeling-commons-frontend/app/components/shared/SocialLink.vue b/apps/modeling-commons-frontend/app/components/shared/SocialLink.vue
index d3adccb6..ffb0196c 100644
--- a/apps/modeling-commons-frontend/app/components/shared/SocialLink.vue
+++ b/apps/modeling-commons-frontend/app/components/shared/SocialLink.vue
@@ -16,7 +16,7 @@
diff --git a/apps/modeling-commons-frontend/app/plugins/force-light-mode.ts b/apps/modeling-commons-frontend/app/plugins/force-light-mode.ts
index 9f5375a6..633a2108 100644
--- a/apps/modeling-commons-frontend/app/plugins/force-light-mode.ts
+++ b/apps/modeling-commons-frontend/app/plugins/force-light-mode.ts
@@ -1,7 +1,10 @@
export default defineNuxtPlugin((nuxtApp) => {
const colorMode = useColorMode();
nuxtApp.hook("app:mounted", () => {
+ colorMode.forced = true;
+ // @ts-expect-error -- backwards compatability
colorMode.preference = "light";
+ // @ts-expect-error -- backwards compatability
colorMode.value = "light";
});
});
diff --git a/apps/modeling-commons-frontend/app/utils/auth.ts b/apps/modeling-commons-frontend/app/utils/auth.ts
index 2c3b06eb..ebcb30c3 100644
--- a/apps/modeling-commons-frontend/app/utils/auth.ts
+++ b/apps/modeling-commons-frontend/app/utils/auth.ts
@@ -34,7 +34,7 @@ function buildAbsoluteAuthUrl(
return url.toString();
}
-export function getSafeNextPath(next: unknown, fallback = authRoutes.models) {
+export function getSafeNextPath(next: unknown, fallback: string = authRoutes.models) {
return normalizeNextPath(next) ?? fallback;
}
diff --git a/apps/modeling-commons-frontend/app/utils/types.ts b/apps/modeling-commons-frontend/app/utils/types.ts
new file mode 100644
index 00000000..2771039a
--- /dev/null
+++ b/apps/modeling-commons-frontend/app/utils/types.ts
@@ -0,0 +1,3 @@
+export type Mutable = {
+ -readonly [P in keyof T]: T[P];
+};
diff --git a/apps/modeling-commons-frontend/nuxt.config.ts b/apps/modeling-commons-frontend/nuxt.config.ts
index a2640bd3..3ac37f48 100644
--- a/apps/modeling-commons-frontend/nuxt.config.ts
+++ b/apps/modeling-commons-frontend/nuxt.config.ts
@@ -189,16 +189,6 @@ export default defineNuxtConfig({
},
],
- typescript: {
- tsConfig: {
- include: ["../tests/**/*"],
- },
- },
-
- csurf: {
- https: process.env.NODE_ENV === "production",
- },
-
linkChecker: { enabled: false },
nitro: {
diff --git a/apps/modeling-commons-frontend/tests/helpers/fixtures.ts b/apps/modeling-commons-frontend/tests/helpers/fixtures.ts
index 63f76a1a..974d4858 100644
--- a/apps/modeling-commons-frontend/tests/helpers/fixtures.ts
+++ b/apps/modeling-commons-frontend/tests/helpers/fixtures.ts
@@ -2,13 +2,15 @@ import type { ResponseSuccessData } from "~/utils/openapi";
export type ModelCard = ResponseSuccessData<"GET", "/api/v1/models/{id}/card">;
-export function makeUser(overrides: Partial<{
- id: string;
- name: string;
- email: string;
- emailVerified: boolean;
- image: string | null;
-}> = {}) {
+export function makeUser(
+ overrides: Partial<{
+ id: string;
+ name: string;
+ email: string;
+ emailVerified: boolean;
+ image: string | null;
+ }> = {},
+) {
return {
id: "user-1",
name: "Ada Lovelace",
@@ -69,6 +71,7 @@ export function makeModelCard(
parentVersionNumber: null,
visibility: overrides.visibility ?? "public",
isEndorsed: overrides.isEndorsed ?? false,
+ isLibraryModel: false,
},
latestVersion: {
modelId: id,
@@ -91,13 +94,15 @@ export function makeModelCard(
};
}
-export function makeDraft(overrides: Partial<{
- id: string;
- userId: string;
- modelId: string | null;
- schemaVersion: number;
- data: Record;
-}> = {}) {
+export function makeDraft(
+ overrides: Partial<{
+ id: string;
+ userId: string;
+ modelId: string | null;
+ schemaVersion: number;
+ data: Record;
+ }> = {},
+) {
return {
id: "draft-1",
userId: "user-1",
diff --git a/apps/modeling-commons-frontend/tests/nuxt/error.test.ts b/apps/modeling-commons-frontend/tests/nuxt/error.test.ts
index 81ef5ac5..e9e655bb 100644
--- a/apps/modeling-commons-frontend/tests/nuxt/error.test.ts
+++ b/apps/modeling-commons-frontend/tests/nuxt/error.test.ts
@@ -21,7 +21,7 @@ const stubs = {
describe("error.vue", () => {
it("renders the 404 status code from props.error.statusCode", async () => {
const wrapper = await mountSuspended(ErrorPage, {
- props: { error: { statusCode: 404, message: "Not found" } },
+ props: { error: createError({ statusCode: 404, message: "Not found" }) },
global: { stubs },
});
expect(wrapper.find('[data-testid="error-code"]').text()).toBe("404");
@@ -29,7 +29,7 @@ describe("error.vue", () => {
it("renders the 500 status code from props.error.statusCode", async () => {
const wrapper = await mountSuspended(ErrorPage, {
- props: { error: { statusCode: 500, message: "Boom" } },
+ props: { error: createError({ statusCode: 500, message: "Boom" }) },
global: { stubs },
});
expect(wrapper.find('[data-testid="error-code"]').text()).toBe("500");
@@ -37,7 +37,7 @@ describe("error.vue", () => {
it("renders the error message from props.error.message", async () => {
const wrapper = await mountSuspended(ErrorPage, {
- props: { error: { statusCode: 500, message: "Internal failure" } },
+ props: { error: createError({ statusCode: 500, message: "Internal failure" }) },
global: { stubs },
});
expect(wrapper.find('[data-testid="error-details"]').text()).toBe("Internal failure");
@@ -45,7 +45,7 @@ describe("error.vue", () => {
it("forwards both ClientNavbar and ClientFooter around the ErrorDisplay", async () => {
const wrapper = await mountSuspended(ErrorPage, {
- props: { error: { statusCode: 404, message: "Not found" } },
+ props: { error: createError({ statusCode: 404, message: "Not found" }) },
global: { stubs },
});
expect(wrapper.find('[data-testid="stub-navbar"]').exists()).toBe(true);
diff --git a/apps/modeling-commons-frontend/tests/nuxt/forms/upload-helpers.test.ts b/apps/modeling-commons-frontend/tests/nuxt/forms/upload-helpers.test.ts
index 17d097ad..e2c0414c 100644
--- a/apps/modeling-commons-frontend/tests/nuxt/forms/upload-helpers.test.ts
+++ b/apps/modeling-commons-frontend/tests/nuxt/forms/upload-helpers.test.ts
@@ -1,9 +1,5 @@
import { describe, expect, it } from "vitest";
-import {
- collectTagNames,
- draftToFormState,
- emptyUploadFormState,
-} from "~/forms/upload";
+import { collectTagNames, draftToFormState, emptyUploadFormState } from "~/forms/upload";
describe("emptyUploadFormState", () => {
it("returns a form with private visibility and empty arrays", () => {
@@ -21,7 +17,8 @@ describe("emptyUploadFormState", () => {
it("returns a fresh object on every call (no shared references)", () => {
const a = emptyUploadFormState();
const b = emptyUploadFormState();
- a.tags.push("mutation");
+ expect(a.tags).toEqual([]);
+ (a.tags as string[]).push("mutation");
expect(b.tags).toEqual([]);
});
});
@@ -132,8 +129,6 @@ describe("draftToFormState", () => {
const { formState } = draftToFormState({
tags: ["agents", "usecase:research"],
});
- expect(collectTagNames(formState).sort()).toEqual(
- ["agents", "usecase:research"].sort(),
- );
+ expect(collectTagNames(formState).sort()).toEqual(["agents", "usecase:research"].sort());
});
});
diff --git a/apps/modeling-commons-frontend/tests/nuxt/middleware/guest.test.ts b/apps/modeling-commons-frontend/tests/nuxt/middleware/guest.test.ts
index ece81f58..cecc664e 100644
--- a/apps/modeling-commons-frontend/tests/nuxt/middleware/guest.test.ts
+++ b/apps/modeling-commons-frontend/tests/nuxt/middleware/guest.test.ts
@@ -12,8 +12,17 @@ mockNuxtImport("useUser", () => () => computed(() => userState.current));
mockNuxtImport("navigateTo", () => navigateToMock);
-const fakeRoute = (fullPath = "/login") =>
- ({ fullPath, path: fullPath, query: {}, params: {}, hash: "", matched: [], meta: {}, name: undefined, redirectedFrom: undefined } as never);
+const fakeRoute = (fullPath = "/login") => ({
+ fullPath,
+ path: fullPath,
+ query: {},
+ params: {},
+ hash: "",
+ matched: [],
+ meta: {},
+ name: undefined,
+ redirectedFrom: undefined,
+});
beforeEach(() => {
vi.resetAllMocks();
@@ -24,7 +33,10 @@ beforeEach(() => {
describe("guest middleware", () => {
it("redirects authenticated users to the fallback path when there is no next param", async () => {
userState.current = { isLoggedIn: true };
- await (guestMiddleware as unknown as (to: unknown, from: unknown) => unknown)(fakeRoute(), fakeRoute());
+ await (guestMiddleware as unknown as (to: unknown, from: unknown) => unknown)(
+ fakeRoute(),
+ fakeRoute(),
+ );
expect(navigateToMock).toHaveBeenCalledWith("/");
});
@@ -32,13 +44,19 @@ describe("guest middleware", () => {
it("honors the `next` query param when authenticated", async () => {
userState.current = { isLoggedIn: true };
const route = { ...fakeRoute(), query: { next: "/models" } };
- await (guestMiddleware as unknown as (to: unknown, from: unknown) => unknown)(route as never, fakeRoute());
+ await (guestMiddleware as unknown as (to: unknown, from: unknown) => unknown)(
+ route,
+ fakeRoute(),
+ );
expect(navigateToMock).toHaveBeenCalledWith("/models");
});
it("does nothing for guests", async () => {
- const result = await (guestMiddleware as unknown as (to: unknown, from: unknown) => unknown)(fakeRoute(), fakeRoute());
+ const result = await (guestMiddleware as unknown as (to: unknown, from: unknown) => unknown)(
+ fakeRoute(),
+ fakeRoute(),
+ );
expect(navigateToMock).not.toHaveBeenCalled();
expect(result).toBeUndefined();
});
diff --git a/apps/modeling-commons-frontend/vitest.config.ts b/apps/modeling-commons-frontend/vitest.config.ts
index b339560b..09a11fdf 100644
--- a/apps/modeling-commons-frontend/vitest.config.ts
+++ b/apps/modeling-commons-frontend/vitest.config.ts
@@ -70,7 +70,6 @@ export default defineConfig({
},
pool: "forks",
maxWorkers: 1,
- minWorkers: 1,
hookTimeout: 30_000,
},
}),
diff --git a/packages/utils/src/std/graph.ts b/packages/utils/src/std/graph.ts
index 34e2c8df..2b4876c8 100644
--- a/packages/utils/src/std/graph.ts
+++ b/packages/utils/src/std/graph.ts
@@ -1,9 +1,7 @@
type RK = string | number | symbol;
-type Tree> = Array>;
-type Node> = T & {
- [K in RK]?: Array>;
-};
+type Node = T & { [P in K]?: Array> };
+type Tree = Array>;
enum WalkOptions {
SKIP = "skip",
@@ -11,36 +9,27 @@ enum WalkOptions {
CONTINUE = "continue",
}
-type WalkCallback> = (
- node: Node,
+type WalkCallback = (
+ node: Node,
index: number,
- parent: Tree | Node | null,
+ parent: Tree | Node | null,
// eslint-disable-next-line @typescript-eslint/no-invalid-void-type
) => void | WalkOptions | Promise;
-function hasChildren>(
- node: Node,
- recursionKey: RK,
-): boolean {
- return Array.isArray(node[recursionKey]) && node[recursionKey].length > 0;
+function getChildren(node: Node, recursionKey: K): Array> {
+ const value = (node as Record)[recursionKey];
+ return Array.isArray(value) ? (value as Array>) : [];
}
-function getChildren>(
- node: Node,
- recursionKey: RK,
-): Array> {
- return node[recursionKey] ?? [];
-}
-
-async function walk>(
- tree: Tree,
- callback: WalkCallback,
- recursionKey: RK,
+async function walk(
+ tree: Tree,
+ callback: WalkCallback,
+ recursionKey: K,
): Promise {
const visit = async (
- node: Node,
+ node: Node,
index: number,
- parent: Tree | Node | null,
+ parent: Tree | Node | null,
// eslint-disable-next-line @typescript-eslint/no-invalid-void-type
): Promise => {
const res = await callback(node, index, parent);
@@ -48,7 +37,7 @@ async function walk>(
return WalkOptions.EXIT;
}
- if (res !== WalkOptions.SKIP && hasChildren(node, recursionKey)) {
+ if (res !== WalkOptions.SKIP) {
const children = getChildren(node, recursionKey);
for (let i = 0; i < children.length; i++) {
const child = children[i];
From e58f2c93daae8425c8844e6ed3cc448b8883f7fc Mon Sep 17 00:00:00 2001
From: Omar Ibrahim
Date: Fri, 5 Jun 2026 14:04:03 -0500
Subject: [PATCH 004/101] chore(modeling-commons): clean up
---
apps/modeling-commons-frontend/TASKS.md | 6 ------
1 file changed, 6 deletions(-)
delete mode 100644 apps/modeling-commons-frontend/TASKS.md
diff --git a/apps/modeling-commons-frontend/TASKS.md b/apps/modeling-commons-frontend/TASKS.md
deleted file mode 100644
index 9da2896c..00000000
--- a/apps/modeling-commons-frontend/TASKS.md
+++ /dev/null
@@ -1,6 +0,0 @@
-- Add ClientOnly and page rules when appropriate
- - This is when the content of the page is not needed for pre-render, like uploading a model or editing a model. This is to prevent hydration errors and to speed up the build process.
-
-- Normalize error handling, toasts, throws, error shows by refactoring then add observability.
-
-- Fix any hydration errors (and their causes, not just the symptoms) that are present in the app.
From d04b543243af3840f57a8356a152d8eb28430370 Mon Sep 17 00:00:00 2001
From: Omar Ibrahim
Date: Mon, 8 Jun 2026 12:24:15 -0500
Subject: [PATCH 005/101] ci(modeling-commons): initial implementation of CI
testing
---
.github/workflows/test-modeling-commons.yml | 77 +++++++++++++++++++
.../docker-compose.dev.yml | 48 +++---------
apps/modeling-commons-backend/src/lib/auth.ts | 2 +-
apps/modeling-commons-frontend/nuxt.config.ts | 21 +++++
.../tests/e2e/auth-password-reset.test.ts | 9 ++-
.../tests/e2e/auth-signup-login.test.ts | 8 +-
.../tests/e2e/helpers/auth.ts | 5 +-
.../tests/e2e/helpers/page.ts | 40 ++++++++++
.../tests/e2e/model-detail-tabs.test.ts | 3 +-
.../tests/e2e/models-browse.test.ts | 38 ++++-----
.../tests/e2e/smoke.test.ts | 40 ++++++----
11 files changed, 212 insertions(+), 79 deletions(-)
create mode 100644 .github/workflows/test-modeling-commons.yml
create mode 100644 apps/modeling-commons-frontend/tests/e2e/helpers/page.ts
diff --git a/.github/workflows/test-modeling-commons.yml b/.github/workflows/test-modeling-commons.yml
new file mode 100644
index 00000000..f8e056f3
--- /dev/null
+++ b/.github/workflows/test-modeling-commons.yml
@@ -0,0 +1,77 @@
+name: Test Modeling Commons
+
+on:
+ push:
+ branches: [main, app/modeling-commons, staging/modeling-commons]
+ paths:
+ - 'apps/modeling-commons-backend/**'
+ - 'apps/modeling-commons-frontend/**'
+ - 'packages/modeling-commons-shared/**'
+ - 'packages/typescript-config/**'
+ - 'packages/tailwind-config/**'
+ - 'packages/nuxt-core/**'
+ - 'packages/vue-ui/**'
+ - 'packages/markdown/**'
+ - 'packages/eslint-config/**'
+ - 'packages/utils/**'
+ - 'packages/common-data/**'
+ - 'packages/template/**'
+ - 'packages/nuxt-content-assets/**'
+ - 'packages/netlogo-docs/**'
+ - 'packages/emails/**'
+ - 'yarn.lock'
+ - '.github/workflows/build-and-push-modeling-commons.yml'
+ - '.github/workflows/test-modeling-commons.yml'
+ workflow_dispatch:
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ environment: netlogo1
+ permissions:
+ contents: read
+ packages: read
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-node@v4
+ with:
+ node-version: '24'
+ cache: yarn
+
+ - name: Install dependencies
+ run: yarn run init
+ env:
+ HELIO_SKIP_CHECKS: '1'
+ NUXT_TELEMETRY_DISABLED: '1'
+
+ - name: Build backend
+ run: yarn workspace modeling-commons-backend fastify:build
+ env:
+ HELIO_HEADLESS: 1
+
+ - name: Run services
+ run: docker compose -f docker-compose.dev.yml up -d --wait
+ working-directory: apps/modeling-commons-backend
+
+ - name: Seed the services
+ run: yarn run db:migrate:dev && yarn run db:seed
+ working-directory: apps/modeling-commons-backend
+
+ - name: Run backend tests
+ run: yarn run test
+ working-directory: apps/modeling-commons-backend
+
+ - name: Run backend server
+ run: node dist/src/index.ts &
+ timeout 30 bash -c 'until curl -sf http://localhost:3000/health; do sleep 1; done'
+ working-directory: apps/modeling-commons-backend
+
+ - name: Run frontend tests
+ run: yarn run test:all
+ working-directory: apps/modeling-commons-frontend
+
+ - if: always()
+ run: docker compose -f docker-compose.dev.yml down -v
+ working-directory: apps/modeling-commons-backend
\ No newline at end of file
diff --git a/apps/modeling-commons-backend/docker-compose.dev.yml b/apps/modeling-commons-backend/docker-compose.dev.yml
index cf8e1ccb..48453308 100644
--- a/apps/modeling-commons-backend/docker-compose.dev.yml
+++ b/apps/modeling-commons-backend/docker-compose.dev.yml
@@ -24,43 +24,19 @@ services:
volumes:
- /tmp/com.netlogo.modeling-commons/postgresql:/var/lib/postgresql
- seaweedfs-master:
- image: chrislusf/seaweedfs:latest
- command: "master -ip=seaweedfs-master -ip.bind=0.0.0.0"
+ seaweedfs:
+ image: chrislusf/seaweedfs:4.27
+ command: "server -s3 -ip.bind=0.0.0.0"
ports:
- - 9333:9333 # Master HTTP
- - 19333:19333 # Master gRPC
- volumes:
- - /tmp/com.netlogo.modeling-commons/seaweedfs/master:/data
- networks:
- - app_network
-
- seaweedfs-volume:
- image: chrislusf/seaweedfs:latest
- command: "volume -mserver=seaweedfs-master:9333 -ip.bind=0.0.0.0 -port=8080"
- ports:
- - 8080:8081 # Volume HTTP
- - 18080:18080 # Volume gRPC
- depends_on:
- - seaweedfs-master
- volumes:
- - /tmp/com.netlogo.modeling-commons/seaweedfs/volume:/data
- networks:
- - app_network
-
- seaweedfs-filer:
- image: chrislusf/seaweedfs:latest
- command: "filer -master=seaweedfs-master:9333 -ip.bind=0.0.0.0 -s3"
- ports:
- - 8888:8888 # Filer HTTP
- - 8333:8333 # S3 API
- - 18888:18888 # Filer gRPC
- depends_on:
- - seaweedfs-master
- - seaweedfs-volume
- volumes:
- - /tmp/com.netlogo.modeling-commons/seaweedfs/filer:/data
- - ./docker/seaweedfs/s3.json:/etc/seaweedfs/s3.json:ro
+ - 9333:9333 # master
+ - 8080:8080 # volume
+ - 8888:8888 # filer
+ - 8333:8333 # S3
+ healthcheck:
+ test: ["CMD", "wget", "-qO-", "http://localhost:8888/"]
+ interval: 5s
+ timeout: 3s
+ retries: 10
networks:
- app_network
diff --git a/apps/modeling-commons-backend/src/lib/auth.ts b/apps/modeling-commons-backend/src/lib/auth.ts
index 2542bac5..90f2e1f2 100644
--- a/apps/modeling-commons-backend/src/lib/auth.ts
+++ b/apps/modeling-commons-backend/src/lib/auth.ts
@@ -13,7 +13,7 @@ export const auth = betterAuth({
baseURL: env.auth.url,
basePath: '/api/auth',
secret: env.auth.secret,
- trustedOrigins: env.cors.allowedOrigins,
+ trustedOrigins: env.isDevelopment ? ['*'] : env.cors.allowedOrigins,
user: {
additionalFields: {
diff --git a/apps/modeling-commons-frontend/nuxt.config.ts b/apps/modeling-commons-frontend/nuxt.config.ts
index 3ac37f48..e38139ab 100644
--- a/apps/modeling-commons-frontend/nuxt.config.ts
+++ b/apps/modeling-commons-frontend/nuxt.config.ts
@@ -73,6 +73,17 @@ export default defineNuxtConfig({
hooks: {
"pages:extend"(pages) {
+ // In test mode (@nuxt/test-utils) Nuxt stops ignoring *.test/*.spec files,
+ // so co-located tests under pages/ get scanned as routes and break the
+ // production build. Drop them here regardless of mode.
+ // --Omar Ibrahim, Jun 05 26
+ const isTestFile = (p: string) => /\.(test|spec)\.[cm]?[jt]sx?$/.test(p);
+ for (let i = pages.length - 1; i >= 0; i--) {
+ const page = pages[i];
+ if (!page?.file) continue;
+ if (isTestFile(page.file)) pages.splice(i, 1);
+ }
+
pages.push({
name: "model-slug",
path: "/models/:slug/:id",
@@ -198,6 +209,16 @@ export default defineNuxtConfig({
crawlLinks: false,
routes: [],
},
+ // Nitro's esbuild defaults to es2019, which rejects top-level await (e.g.
+ // `await import("zod")` in SocialLink). Prod builds externalize those chunks
+ // so it never bites, but test-mode (@nuxt/test-utils) inlines them into the
+ // server bundle. Our runtime is Node 24, so es2022 is safe and supports TLA.
+ // -- Omar Ibrahim, Jun 05 26
+ esbuild: {
+ options: {
+ target: "es2022",
+ },
+ },
},
// @disable-after-beta
diff --git a/apps/modeling-commons-frontend/tests/e2e/auth-password-reset.test.ts b/apps/modeling-commons-frontend/tests/e2e/auth-password-reset.test.ts
index 7f0189f2..7233e84c 100644
--- a/apps/modeling-commons-frontend/tests/e2e/auth-password-reset.test.ts
+++ b/apps/modeling-commons-frontend/tests/e2e/auth-password-reset.test.ts
@@ -29,9 +29,14 @@ describe("auth: password reset request", async () => {
.waitFor({ timeout: 15_000 }),
]);
- // URL should reflect the email param after the success branch.
+ // URL should reflect the email param after the success branch. The page
+ // shows the alert before awaiting navigateTo({ query: { email } }), so wait
+ // for the URL to catch up rather than reading it immediately.
if (await page.getByText(/Check your inbox/i).isVisible()) {
- expect(page.url()).toContain(encodeURIComponent(email));
+ await page.waitForURL((u) => u.searchParams.get("email") === email, {
+ timeout: 15_000,
+ });
+ expect(new URL(page.url()).searchParams.get("email")).toBe(email);
}
await page.close();
diff --git a/apps/modeling-commons-frontend/tests/e2e/auth-signup-login.test.ts b/apps/modeling-commons-frontend/tests/e2e/auth-signup-login.test.ts
index 1dc2e172..90f2401e 100644
--- a/apps/modeling-commons-frontend/tests/e2e/auth-signup-login.test.ts
+++ b/apps/modeling-commons-frontend/tests/e2e/auth-signup-login.test.ts
@@ -43,7 +43,9 @@ describe("auth: signup + login", async () => {
await Promise.race([
page.waitForURL(/\/verify-email/, { timeout: 15_000 }),
page
- .getByText(/verify your email|Login failed/i)
+ .locator("[data-slot='title']")
+ .filter({ hasText: /verify your email|Login failed/i })
+ .first()
.waitFor({ timeout: 15_000 }),
]);
@@ -61,7 +63,9 @@ describe("auth: signup + login", async () => {
await page.getByRole("button", { name: "Log In" }).click();
await page
- .getByText(/Login failed|verify your email/i)
+ .locator("[data-slot='title']")
+ .filter({ hasText: /Login failed|verify your email/i })
+ .first()
.waitFor({ timeout: 15_000 });
expect(page.url()).not.toMatch(/\/models\/?$/);
diff --git a/apps/modeling-commons-frontend/tests/e2e/helpers/auth.ts b/apps/modeling-commons-frontend/tests/e2e/helpers/auth.ts
index 8213d7c3..b259188d 100644
--- a/apps/modeling-commons-frontend/tests/e2e/helpers/auth.ts
+++ b/apps/modeling-commons-frontend/tests/e2e/helpers/auth.ts
@@ -1,4 +1,5 @@
import type { Page } from "playwright-core";
+import { url } from "@nuxt/test-utils/e2e";
export interface SignedUpUser {
email: string;
@@ -19,7 +20,7 @@ export function buildRandomUser(): SignedUpUser {
export async function signUpRandomUser(page: Page): Promise {
const user = buildRandomUser();
- await page.goto("/signup");
+ await page.goto(url("/signup"));
await page.getByLabel("Name").fill(user.name);
await page.getByLabel("Email").fill(user.email);
@@ -36,7 +37,7 @@ export async function signIn(
page: Page,
creds: { email: string; password: string },
): Promise {
- await page.goto("/login");
+ await page.goto(url("/login"));
await page.getByLabel("Email").fill(creds.email);
await page.getByLabel("Password", { exact: true }).fill(creds.password);
diff --git a/apps/modeling-commons-frontend/tests/e2e/helpers/page.ts b/apps/modeling-commons-frontend/tests/e2e/helpers/page.ts
new file mode 100644
index 00000000..0c7e22d5
--- /dev/null
+++ b/apps/modeling-commons-frontend/tests/e2e/helpers/page.ts
@@ -0,0 +1,40 @@
+import type { Page } from "playwright-core";
+import { expect } from "vitest";
+
+/**
+ * The clickable target for a model card on `/models`.
+ *
+ * Each card (`UBlogPost`) is a full-card overlay link rendered as
+ * ``. The `` itself
+ * collapses to zero height (its only child is absolutely positioned), so it
+ * reports as hidden and `.click()`/`.waitFor()` on it time out. The stretched
+ * `span` is the actual visible, clickable hit area — and there's exactly one per
+ * card, so it also works for `.count()`.
+ */
+export const modelCardLink = "a[href^='/models/'] span";
+
+/**
+ * Asserts the loaded page is the expected one.
+ *
+ * Tolerant of pages whose identity lives only in the document `` rather
+ * than a visible heading — e.g. `/models`, whose first rendered heading is a
+ * dynamic model-card title and whose "Explore Models" label exists only in the
+ * SSR title. Matches `identity` against the first heading's text OR the title;
+ * a heading need not exist (an empty `/models` renders none).
+ */
+export async function expectPageIdentity(
+ page: Page,
+ identity: RegExp,
+ label = "",
+): Promise {
+ const heading = page.locator("h1, h2, h3, h4, h5, h6").first();
+ const headingText = await heading
+ .textContent({ timeout: 15_000 })
+ .catch(() => null);
+ const title = await page.title();
+ const prefix = label ? `${label}: ` : "";
+ expect(
+ (headingText !== null && identity.test(headingText)) || identity.test(title),
+ `${prefix}expected heading or title to match ${identity} (heading=${JSON.stringify(headingText)}, title=${JSON.stringify(title)})`,
+ ).toBe(true);
+}
diff --git a/apps/modeling-commons-frontend/tests/e2e/model-detail-tabs.test.ts b/apps/modeling-commons-frontend/tests/e2e/model-detail-tabs.test.ts
index fc753376..54b9ca80 100644
--- a/apps/modeling-commons-frontend/tests/e2e/model-detail-tabs.test.ts
+++ b/apps/modeling-commons-frontend/tests/e2e/model-detail-tabs.test.ts
@@ -15,6 +15,7 @@
import { describe, expect, it } from "vitest";
import { createPage, url } from "@nuxt/test-utils/e2e";
import { e2eSetup } from "./setup";
+import { modelCardLink } from "./helpers/page";
describe("model detail: tabs and likes", async () => {
await e2eSetup();
@@ -23,7 +24,7 @@ describe("model detail: tabs and likes", async () => {
const page = await createPage();
await page.goto(url("/models"));
- const firstCard = page.locator("a[href^='/models/']").first();
+ const firstCard = page.locator(modelCardLink).first();
if (!(await firstCard.count())) {
await page.close();
return; // empty seed; nothing to test
diff --git a/apps/modeling-commons-frontend/tests/e2e/models-browse.test.ts b/apps/modeling-commons-frontend/tests/e2e/models-browse.test.ts
index ea49391a..c7fb8e81 100644
--- a/apps/modeling-commons-frontend/tests/e2e/models-browse.test.ts
+++ b/apps/modeling-commons-frontend/tests/e2e/models-browse.test.ts
@@ -5,6 +5,16 @@
import { describe, expect, it } from "vitest";
import { createPage, url } from "@nuxt/test-utils/e2e";
import { e2eSetup } from "./setup";
+import { expectPageIdentity, modelCardLink } from "./helpers/page";
+
+// /models renders "Explore Models" only in the document ; its visible
+// headings are dynamic model-card titles. Wait for cards or the empty state
+// instead of a fixed heading.
+const cardOrEmpty = (page: Awaited>) =>
+ Promise.race([
+ page.locator(modelCardLink).first().waitFor({ timeout: 15_000 }),
+ page.getByText(/No models found/i).waitFor({ timeout: 15_000 }),
+ ]);
describe("models: browse", async () => {
await e2eSetup();
@@ -19,15 +29,8 @@ describe("models: browse", async () => {
const response = await page.goto(url("/models"));
expect(response?.ok()).toBe(true);
- await page
- .getByRole("heading", { name: /Explore Models/i })
- .waitFor({ timeout: 15_000 });
-
- // Either at least one ModelCard renders, or the empty state shows up.
- await Promise.race([
- page.locator("a[href^='/models/']").first().waitFor({ timeout: 15_000 }),
- page.getByText(/No models found/i).waitFor({ timeout: 15_000 }),
- ]);
+ await expectPageIdentity(page, /Explore Models/i, "/models");
+ await cardOrEmpty(page);
expect(errors).toEqual([]);
await page.close();
@@ -37,11 +40,10 @@ describe("models: browse", async () => {
const page = await createPage();
await page.goto(url("/models"));
- await page
- .getByRole("heading", { name: /Explore Models/i })
- .waitFor({ timeout: 15_000 });
+ await expectPageIdentity(page, /Explore Models/i, "/models");
+ await cardOrEmpty(page);
- const initialCount = await page.locator("a[href^='/models/']").count();
+ const initialCount = await page.locator(modelCardLink).count();
const searchInput = page.getByRole("textbox").first();
await searchInput.fill("zzznoresultsexpected" + Date.now());
@@ -52,7 +54,7 @@ describe("models: browse", async () => {
async () => {
const empty = await page.getByText(/No models found/i).isVisible();
if (empty) return "empty";
- const count = await page.locator("a[href^='/models/']").count();
+ const count = await page.locator(modelCardLink).count();
return count < initialCount ? "smaller" : "same";
},
{ timeout: 10_000 },
@@ -66,7 +68,7 @@ describe("models: browse", async () => {
const page = await createPage();
await page.goto(url("/models"));
- const firstCard = page.locator("a[href^='/models/']").first();
+ const firstCard = page.locator(modelCardLink).first();
const exists = await firstCard.count();
if (!exists) {
// Empty seed — there's nothing to click into. Skip rather than fail.
@@ -77,10 +79,10 @@ describe("models: browse", async () => {
await firstCard.click();
await page.waitForURL(/\/models\/.+/, { timeout: 15_000 });
- // Header / hero region should render. Use the "Back to models" button as
- // a stable anchor — it appears at the top of the [id] page.
+ // Detail page rendered: the tab bar (Discussion/Files/Versions/Family) is a
+ // stable anchor present on every model detail page.
await page
- .getByRole("link", { name: /Back to models/i })
+ .getByRole("button", { name: /^Discussion$/ })
.waitFor({ timeout: 15_000 });
await page.close();
diff --git a/apps/modeling-commons-frontend/tests/e2e/smoke.test.ts b/apps/modeling-commons-frontend/tests/e2e/smoke.test.ts
index afef1e92..8bd228c4 100644
--- a/apps/modeling-commons-frontend/tests/e2e/smoke.test.ts
+++ b/apps/modeling-commons-frontend/tests/e2e/smoke.test.ts
@@ -1,24 +1,31 @@
-// E2E smoke: each public page loads with a 200, renders a heading, and emits
-// no console errors during navigation.
+// E2E smoke: each public page loads with a 200, resolves to the right page
+// (by heading or document title), and emits no console errors during navigation.
import { describe, expect, it } from "vitest";
import { createPage, url } from "@nuxt/test-utils/e2e";
import { e2eSetup } from "./setup";
-
-const PAGES: Array<{ path: string; heading: RegExp }> = [
- { path: "/", heading: /./ },
- { path: "/about", heading: /./ },
- { path: "/donate", heading: /./ },
- { path: "/models", heading: /Explore Models/i },
- { path: "/login", heading: /Log In|Welcome back/i },
- { path: "/signup", heading: /Sign Up/i },
+import { expectPageIdentity } from "./helpers/page";
+
+const PAGES: Array<{ path: string; identity: RegExp }> = [
+ { path: "/", identity: /./ },
+ { path: "/about", identity: /./ },
+ { path: "/donate", identity: /./ },
+ { path: "/models", identity: /Explore Models/i },
+ { path: "/featured-models", identity: /Featured Models/i },
+ { path: "/new-models", identity: /New Models/i },
+ { path: "/login", identity: /Log In|Welcome back/i },
+ { path: "/signup", identity: /Sign Up/i },
+ { path: "/reset-password", identity: /Reset password/i },
+ { path: "/privacy", identity: /Privacy Policy/i },
+ { path: "/terms-of-service", identity: /Terms of Service/i },
+ { path: "/cookies", identity: /Cookie Policy/i },
];
describe("smoke: public pages", async () => {
await e2eSetup();
- for (const { path, heading } of PAGES) {
- it(`loads ${path} with a 200, heading visible, no console errors`, async () => {
+ for (const { path, identity } of PAGES) {
+ it(`loads ${path} with a 200, correct page, no console errors`, async () => {
const page = await createPage();
const errors: string[] = [];
page.on("console", (msg) => {
@@ -32,11 +39,10 @@ describe("smoke: public pages", async () => {
expect(response, `no response for ${path}`).toBeTruthy();
expect(response?.ok(), `non-2xx for ${path}`).toBe(true);
- // Either an h1 or any visible heading should appear.
- const headingLocator = page.locator("h1, h2, h3, h4, h5, h6").first();
- await headingLocator.waitFor({ timeout: 15_000 });
- const text = (await headingLocator.textContent()) ?? "";
- expect(text).toMatch(heading);
+ await expectPageIdentity(page, identity, path);
+
+ const title = await page.title();
+ expect(title.trim(), `empty on ${path}`).not.toBe("");
expect(errors, `console errors on ${path}`).toEqual([]);
From e82a06d9f315edb6070404794d450be8544377b1 Mon Sep 17 00:00:00 2001
From: Omar Ibrahim
Date: Mon, 8 Jun 2026 13:09:46 -0500
Subject: [PATCH 006/101] minor(modeling-commons): remove (now) irrelevant
ts-expect-error
---
apps/modeling-commons-backend/prisma/seed/builders.ts | 1 -
1 file changed, 1 deletion(-)
diff --git a/apps/modeling-commons-backend/prisma/seed/builders.ts b/apps/modeling-commons-backend/prisma/seed/builders.ts
index 634f70bf..7c3b84c6 100644
--- a/apps/modeling-commons-backend/prisma/seed/builders.ts
+++ b/apps/modeling-commons-backend/prisma/seed/builders.ts
@@ -161,7 +161,6 @@ export class ModelVersionBuilder {
},
},
update: {},
- // @ts-expect-error -- Shared vs. Non-Shared Buffer
create: data,
});
From 7b2e13c2549369c29bb4c024c4ff08af4844f3cf Mon Sep 17 00:00:00 2001
From: Omar Ibrahim
Date: Mon, 8 Jun 2026 13:23:44 -0500
Subject: [PATCH 007/101] ci(modeling-commons): supply s3.json to seaweed for
testing/development environments
---
apps/modeling-commons-backend/docker-compose.dev.yml | 5 ++++-
1 file changed, 4 insertions(+), 1 deletion(-)
diff --git a/apps/modeling-commons-backend/docker-compose.dev.yml b/apps/modeling-commons-backend/docker-compose.dev.yml
index 48453308..c869bbc7 100644
--- a/apps/modeling-commons-backend/docker-compose.dev.yml
+++ b/apps/modeling-commons-backend/docker-compose.dev.yml
@@ -26,12 +26,15 @@ services:
seaweedfs:
image: chrislusf/seaweedfs:4.27
- command: "server -s3 -ip.bind=0.0.0.0"
+ command: "mini -dir=/data -s3 -s3.config=/etc/seaweedfs/s3.json -s3.defaultFileMode=0644 -ip.bind=0.0.0.0"
ports:
- 9333:9333 # master
- 8080:8080 # volume
- 8888:8888 # filer
- 8333:8333 # S3
+ volumes:
+ - /tmp/com.netlogo.modeling-commons/seaweedfs/data:/data
+ - ./docker/seaweedfs/s3.json:/etc/seaweedfs/s3.json:ro
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:8888/"]
interval: 5s
From 2f86bf1246aed00282bbbf26c0fee5f6bccdfa64 Mon Sep 17 00:00:00 2001
From: Omar Ibrahim
Date: Mon, 8 Jun 2026 13:41:03 -0500
Subject: [PATCH 008/101] ci(modeling-commons): prevent pending work from
failing CI
---
apps/modeling-commons-backend/cucumber.mjs | 1 +
1 file changed, 1 insertion(+)
diff --git a/apps/modeling-commons-backend/cucumber.mjs b/apps/modeling-commons-backend/cucumber.mjs
index 2615d058..98a1ae9e 100644
--- a/apps/modeling-commons-backend/cucumber.mjs
+++ b/apps/modeling-commons-backend/cucumber.mjs
@@ -21,6 +21,7 @@ const baseConfig = {
'progress-bar',
'@cucumber/pretty-formatter',
],
+ tags: 'not @pending and not @wip and not @skip',
formatOptions: { snippetInterface: 'async-await' },
};
From 4236b9d3d8e02f56bca03fef0d789de46fd66e73 Mon Sep 17 00:00:00 2001
From: Omar Ibrahim
Date: Mon, 8 Jun 2026 13:49:19 -0500
Subject: [PATCH 009/101] ci(modeling-commons): add caching to test CI, fix
wrong health endpoint
---
.github/workflows/test-modeling-commons.yml | 10 +++++++++-
1 file changed, 9 insertions(+), 1 deletion(-)
diff --git a/.github/workflows/test-modeling-commons.yml b/.github/workflows/test-modeling-commons.yml
index f8e056f3..018775b9 100644
--- a/.github/workflows/test-modeling-commons.yml
+++ b/.github/workflows/test-modeling-commons.yml
@@ -40,6 +40,14 @@ jobs:
node-version: '24'
cache: yarn
+ - uses: actions/cache@v4
+ with:
+ path: node_modules/.cache/turbo
+ key: turbo-${{ runner.os }}-${{ hashFiles('yarn.lock') }}-${{ github.sha }}
+ restore-keys: |
+ turbo-${{ runner.os }}-${{ hashFiles('yarn.lock') }}-
+ turbo-${{ runner.os }}-
+
- name: Install dependencies
run: yarn run init
env:
@@ -65,7 +73,7 @@ jobs:
- name: Run backend server
run: node dist/src/index.ts &
- timeout 30 bash -c 'until curl -sf http://localhost:3000/health; do sleep 1; done'
+ timeout 30 bash -c 'until curl -sf http://localhost:3000/api/health; do sleep 1; done'
working-directory: apps/modeling-commons-backend
- name: Run frontend tests
From 8bf70582c7c17f8aec986255af615b1fb6ae8ce9 Mon Sep 17 00:00:00 2001
From: Omar Ibrahim
Date: Mon, 8 Jun 2026 13:51:08 -0500
Subject: [PATCH 010/101] ci(modeling-commons): cache node_modules
---
.github/workflows/test-modeling-commons.yml | 10 ++++++++++
1 file changed, 10 insertions(+)
diff --git a/.github/workflows/test-modeling-commons.yml b/.github/workflows/test-modeling-commons.yml
index 018775b9..c6a5729b 100644
--- a/.github/workflows/test-modeling-commons.yml
+++ b/.github/workflows/test-modeling-commons.yml
@@ -48,6 +48,16 @@ jobs:
turbo-${{ runner.os }}-${{ hashFiles('yarn.lock') }}-
turbo-${{ runner.os }}-
+ - uses: actions/cache@v4
+ with:
+ path: |
+ node_modules
+ apps/*/node_modules
+ packages/*/node_modules
+ key: node-modules-${{ runner.os }}-${{ hashFiles('yarn.lock') }}
+ restore-keys: |
+ node-modules-${{ runner.os }}-
+
- name: Install dependencies
run: yarn run init
env:
From 1883333f89804aa832bc1b0410b34ee10cfea655 Mon Sep 17 00:00:00 2001
From: Omar Ibrahim
Date: Mon, 8 Jun 2026 14:51:06 -0500
Subject: [PATCH 011/101] ci(modeling-commons): unblock e2e tests on frontend
---
apps/modeling-commons-frontend/package.json | 2 +-
.../tests/e2e/auth-password-reset.test.ts | 3 ++-
.../tests/e2e/auth-signup-login.test.ts | 13 ++++++----
.../tests/e2e/helpers/auth.ts | 13 +++++-----
.../tests/e2e/helpers/form.ts | 26 +++++++++++++++++++
.../tests/e2e/models-browse.test.ts | 3 ++-
6 files changed, 46 insertions(+), 14 deletions(-)
create mode 100644 apps/modeling-commons-frontend/tests/e2e/helpers/form.ts
diff --git a/apps/modeling-commons-frontend/package.json b/apps/modeling-commons-frontend/package.json
index 472f4123..d6d64c11 100644
--- a/apps/modeling-commons-frontend/package.json
+++ b/apps/modeling-commons-frontend/package.json
@@ -25,7 +25,7 @@
"test:components": "vitest run --project=components",
"test:nuxt": "vitest run --project=nuxt",
"test:bff": "vitest run --project=bff",
- "test:e2e": "vitest run --project=e2e",
+ "test:e2e": "NODE_OPTIONS=--dns-result-order=ipv4first vitest run --project=e2e",
"check-types": "nuxi typecheck",
"lint": "eslint . --max-warnings 0",
"lint:fix": "eslint . --fix",
diff --git a/apps/modeling-commons-frontend/tests/e2e/auth-password-reset.test.ts b/apps/modeling-commons-frontend/tests/e2e/auth-password-reset.test.ts
index 7233e84c..53ce50ff 100644
--- a/apps/modeling-commons-frontend/tests/e2e/auth-password-reset.test.ts
+++ b/apps/modeling-commons-frontend/tests/e2e/auth-password-reset.test.ts
@@ -8,6 +8,7 @@
import { describe, expect, it } from "vitest";
import { createPage, url } from "@nuxt/test-utils/e2e";
import { e2eSetup } from "./setup";
+import { fillField } from "./helpers/form";
describe("auth: password reset request", async () => {
await e2eSetup();
@@ -17,7 +18,7 @@ describe("auth: password reset request", async () => {
await page.goto(url("/reset-password"));
const email = `reset+${Date.now()}@example.test`;
- await page.getByLabel("Email").fill(email);
+ await fillField(page.getByLabel("Email"), email);
await page.getByRole("button", { name: "Send reset link" }).click();
// The page shows either a success alert (any input) or an error alert
diff --git a/apps/modeling-commons-frontend/tests/e2e/auth-signup-login.test.ts b/apps/modeling-commons-frontend/tests/e2e/auth-signup-login.test.ts
index 90f2401e..973d839c 100644
--- a/apps/modeling-commons-frontend/tests/e2e/auth-signup-login.test.ts
+++ b/apps/modeling-commons-frontend/tests/e2e/auth-signup-login.test.ts
@@ -10,6 +10,7 @@ import { describe, expect, it } from "vitest";
import { createPage, url } from "@nuxt/test-utils/e2e";
import { e2eSetup } from "./setup";
import { buildRandomUser, signUpRandomUser } from "./helpers/auth";
+import { fillField } from "./helpers/form";
describe("auth: signup + login", async () => {
await e2eSetup();
@@ -24,7 +25,9 @@ describe("auth: signup + login", async () => {
const user = await signUpRandomUser(page);
expect(page.url()).toContain("/verify-email");
- expect(page.url()).toContain(encodeURIComponent(user.email));
+ // Vue Router doesn't percent-encode `+`/`@` in query values, so compare the
+ // decoded `email` param rather than assuming a specific URL encoding.
+ expect(new URL(page.url()).searchParams.get("email")).toBe(user.email);
expect(errors).toEqual([]);
await page.close();
@@ -35,8 +38,8 @@ describe("auth: signup + login", async () => {
const user = await signUpRandomUser(page);
await page.goto(url("/login"));
- await page.getByLabel("Email").fill(user.email);
- await page.getByLabel("Password", { exact: true }).fill(user.password);
+ await fillField(page.getByLabel("Email"), user.email);
+ await fillField(page.getByLabel("Password", { exact: true }), user.password);
await page.getByRole("button", { name: "Log In" }).click();
// The auth flow either bounces back to /verify-email or surfaces a toast.
@@ -58,8 +61,8 @@ describe("auth: signup + login", async () => {
await page.goto(url("/login"));
const fake = buildRandomUser();
- await page.getByLabel("Email").fill(fake.email);
- await page.getByLabel("Password", { exact: true }).fill(fake.password);
+ await fillField(page.getByLabel("Email"), fake.email);
+ await fillField(page.getByLabel("Password", { exact: true }), fake.password);
await page.getByRole("button", { name: "Log In" }).click();
await page
diff --git a/apps/modeling-commons-frontend/tests/e2e/helpers/auth.ts b/apps/modeling-commons-frontend/tests/e2e/helpers/auth.ts
index b259188d..8cbe2d88 100644
--- a/apps/modeling-commons-frontend/tests/e2e/helpers/auth.ts
+++ b/apps/modeling-commons-frontend/tests/e2e/helpers/auth.ts
@@ -1,5 +1,6 @@
import type { Page } from "playwright-core";
import { url } from "@nuxt/test-utils/e2e";
+import { fillField } from "./form";
export interface SignedUpUser {
email: string;
@@ -22,10 +23,10 @@ export async function signUpRandomUser(page: Page): Promise {
const user = buildRandomUser();
await page.goto(url("/signup"));
- await page.getByLabel("Name").fill(user.name);
- await page.getByLabel("Email").fill(user.email);
- await page.getByLabel("Password", { exact: true }).fill(user.password);
- await page.getByLabel("Confirm Password").fill(user.password);
+ await fillField(page.getByLabel("Name"), user.name);
+ await fillField(page.getByLabel("Email"), user.email);
+ await fillField(page.getByLabel("Password", { exact: true }), user.password);
+ await fillField(page.getByLabel("Confirm Password"), user.password);
await page.getByRole("button", { name: "Sign Up" }).click();
@@ -39,8 +40,8 @@ export async function signIn(
): Promise {
await page.goto(url("/login"));
- await page.getByLabel("Email").fill(creds.email);
- await page.getByLabel("Password", { exact: true }).fill(creds.password);
+ await fillField(page.getByLabel("Email"), creds.email);
+ await fillField(page.getByLabel("Password", { exact: true }), creds.password);
await page.getByRole("button", { name: "Log In" }).click();
await page.waitForURL(/\/(models|passkey|profile)/, { timeout: 30_000 });
diff --git a/apps/modeling-commons-frontend/tests/e2e/helpers/form.ts b/apps/modeling-commons-frontend/tests/e2e/helpers/form.ts
new file mode 100644
index 00000000..2a77d4d2
--- /dev/null
+++ b/apps/modeling-commons-frontend/tests/e2e/helpers/form.ts
@@ -0,0 +1,26 @@
+import type { Locator } from "playwright-core";
+import { expect } from "vitest";
+
+/**
+ * Fill an input so the value survives Nuxt's SSR hydration.
+ *
+ * Right after navigation, Vue hydrates the server-rendered markup and re-renders
+ * the inputs. If Playwright types into a field before hydration settles, the
+ * re-render silently clears it — this reliably wiped the *first* field filled on
+ * a freshly loaded page (e.g. "Name" on /signup, "Email" on /login), so the form
+ * failed validation, never submitted, and `waitForURL` timed out. The result was
+ * an intermittent failure that looked like a backend/CORS problem but was a
+ * client-side race. Re-fill until the value sticks.
+ */
+export async function fillField(locator: Locator, value: string): Promise {
+ await locator.waitFor({ state: "visible" });
+ await expect
+ .poll(
+ async () => {
+ await locator.fill(value);
+ return locator.inputValue();
+ },
+ { timeout: 10_000, interval: 150 },
+ )
+ .toBe(value);
+}
diff --git a/apps/modeling-commons-frontend/tests/e2e/models-browse.test.ts b/apps/modeling-commons-frontend/tests/e2e/models-browse.test.ts
index c7fb8e81..1d991bee 100644
--- a/apps/modeling-commons-frontend/tests/e2e/models-browse.test.ts
+++ b/apps/modeling-commons-frontend/tests/e2e/models-browse.test.ts
@@ -6,6 +6,7 @@ import { describe, expect, it } from "vitest";
import { createPage, url } from "@nuxt/test-utils/e2e";
import { e2eSetup } from "./setup";
import { expectPageIdentity, modelCardLink } from "./helpers/page";
+import { fillField } from "./helpers/form";
// /models renders "Explore Models" only in the document ; its visible
// headings are dynamic model-card titles. Wait for cards or the empty state
@@ -46,7 +47,7 @@ describe("models: browse", async () => {
const initialCount = await page.locator(modelCardLink).count();
const searchInput = page.getByRole("textbox").first();
- await searchInput.fill("zzznoresultsexpected" + Date.now());
+ await fillField(searchInput, "zzznoresultsexpected" + Date.now());
// Debounced 300ms; wait for either fewer cards or the empty state.
await expect
From 99f64ef8433fff7827b44096e98eeca852c04338 Mon Sep 17 00:00:00 2001
From: Omar Ibrahim
Date: Mon, 8 Jun 2026 15:08:12 -0500
Subject: [PATCH 012/101] ci(modeling-commons): install e2e browser in CI,
reuse frontend build across e2e runs
---
.github/workflows/test-modeling-commons.yml | 47 +++++++++++++++++++
.../model/detail/ModelDiscussionTab.test.ts | 4 +-
apps/modeling-commons-frontend/package.json | 3 +-
.../tests/e2e/setup.ts | 8 ++++
4 files changed, 59 insertions(+), 3 deletions(-)
diff --git a/.github/workflows/test-modeling-commons.yml b/.github/workflows/test-modeling-commons.yml
index c6a5729b..de87804a 100644
--- a/.github/workflows/test-modeling-commons.yml
+++ b/.github/workflows/test-modeling-commons.yml
@@ -27,6 +27,7 @@ on:
jobs:
test:
runs-on: ubuntu-latest
+ timeout-minutes: 15
environment: netlogo1
permissions:
contents: read
@@ -86,9 +87,55 @@ jobs:
timeout 30 bash -c 'until curl -sf http://localhost:3000/api/health; do sleep 1; done'
working-directory: apps/modeling-commons-backend
+ - name: Get Playwright version
+ id: pw-version
+ run: echo "version=$(npx playwright --version)" >> "$GITHUB_OUTPUT"
+
+ - uses: actions/cache@v4
+ id: playwright
+ with:
+ path: ~/.cache/ms-playwright
+ key: playwright-${{ runner.os }}-${{ steps.pw-version.outputs.version }}
+
+ # - name: Install Playwright browser
+ # if: steps.playwright.outputs.cache-hit != 'true'
+ # run: yarn run e2e:install-browser
+ # working-directory: apps/modeling-commons-frontend
+
+ - name: Install Playwright deps
+ run: npx playwright-core install-deps chromium
+ working-directory: apps/modeling-commons-frontend
+
+ - name: Install Playwright browser
+ timeout-minutes: 5
+ run: |
+ npx playwright-core install chromium
+ sudo fc-cache -f
+ env:
+ FONTCONFIG_PATH: /dev/null
+ working-directory: apps/modeling-commons-frontend
+
+ - name: Build frontend
+ run: yarn run nuxt:build
+ working-directory: apps/modeling-commons-frontend
+ env:
+ NUXT_TELEMETRY_DISABLED: '1'
+
+ - name: Run frontend server
+ run: node .output/server/index.mjs &
+ timeout 60 bash -c 'until curl -sf http://localhost:3005/ >/dev/null; do sleep 1; done'
+ working-directory: apps/modeling-commons-frontend
+ env:
+ PORT: '3005'
+ NODE_OPTIONS: --dns-result-order=ipv4first
+
+ # E2E runs against the already-built server (E2E_HOST), so the e2e project
+ # skips its per-file Nuxt build — the non-e2e projects are unaffected.
- name: Run frontend tests
run: yarn run test:all
working-directory: apps/modeling-commons-frontend
+ env:
+ E2E_HOST: http://localhost:3005
- if: always()
run: docker compose -f docker-compose.dev.yml down -v
diff --git a/apps/modeling-commons-frontend/app/components/model/detail/ModelDiscussionTab.test.ts b/apps/modeling-commons-frontend/app/components/model/detail/ModelDiscussionTab.test.ts
index da048444..187e8632 100644
--- a/apps/modeling-commons-frontend/app/components/model/detail/ModelDiscussionTab.test.ts
+++ b/apps/modeling-commons-frontend/app/components/model/detail/ModelDiscussionTab.test.ts
@@ -10,8 +10,8 @@ describe("ModelDiscussionTab", () => {
it("renders the empty state copy", async () => {
const wrapper = await mountSuspended(ModelDiscussionTab);
- expect(wrapper.text()).toContain("No discussions yet");
- expect(wrapper.text()).toContain("Be the first to start a conversation");
+ expect(wrapper.text()).toContain("Discussions are coming soon!");
+ expect(wrapper.text()).toContain("please reach out to the author");
});
it("renders a filter selector", async () => {
diff --git a/apps/modeling-commons-frontend/package.json b/apps/modeling-commons-frontend/package.json
index d6d64c11..64827cbc 100644
--- a/apps/modeling-commons-frontend/package.json
+++ b/apps/modeling-commons-frontend/package.json
@@ -26,6 +26,7 @@
"test:nuxt": "vitest run --project=nuxt",
"test:bff": "vitest run --project=bff",
"test:e2e": "NODE_OPTIONS=--dns-result-order=ipv4first vitest run --project=e2e",
+ "e2e:install-browser": "playwright-core install --with-deps chromium",
"check-types": "nuxi typecheck",
"lint": "eslint . --max-warnings 0",
"lint:fix": "eslint . --fix",
@@ -82,7 +83,7 @@
"happy-dom": "^20.9.0",
"openapi-fetch": "^0.17.0",
"openapi-typescript-helpers": "^0.1.0",
- "playwright-core": "^1.59.1",
+ "playwright-core": "^1.60.0",
"sass": "^1.93.3",
"turbo-meta-utilities": "^1.0.1",
"typescript": "^5.4.0",
diff --git a/apps/modeling-commons-frontend/tests/e2e/setup.ts b/apps/modeling-commons-frontend/tests/e2e/setup.ts
index e611ff19..a50bc6ef 100644
--- a/apps/modeling-commons-frontend/tests/e2e/setup.ts
+++ b/apps/modeling-commons-frontend/tests/e2e/setup.ts
@@ -4,10 +4,18 @@ export const E2E_HOST = process.env.E2E_HOST;
type SetupOptions = Parameters[0];
+// In server mode the setup hook builds Nuxt before the browser launches; a cold
+// production build on a 2-core CI runner can exceed @nuxt/test-utils' 120s
+// default and time out the whole suite. Host mode skips the build (build/server
+// are forced off), so this only bites the local server-mode fallback — bump it
+// generously and let CI override via env. CI should prefer host mode (E2E_HOST).
+const SETUP_TIMEOUT = Number(process.env.E2E_SETUP_TIMEOUT ?? 300_000);
+
export async function e2eSetup(extra: SetupOptions = {}) {
return nuxtSetup({
browser: true,
browserOptions: { type: "chromium" },
+ setupTimeout: SETUP_TIMEOUT,
...(E2E_HOST ? { host: E2E_HOST } : { server: true }),
...extra,
});
From cac199696e0e07632d8ac379fe1bb3e3fedc2154 Mon Sep 17 00:00:00 2001
From: Omar Ibrahim
Date: Wed, 10 Jun 2026 13:47:11 -0500
Subject: [PATCH 013/101] ci(modeling-commons): Use cached Chromium over
reinstalling
---
.github/workflows/test-modeling-commons.yml | 17 ++---------------
1 file changed, 2 insertions(+), 15 deletions(-)
diff --git a/.github/workflows/test-modeling-commons.yml b/.github/workflows/test-modeling-commons.yml
index de87804a..30c5bc4c 100644
--- a/.github/workflows/test-modeling-commons.yml
+++ b/.github/workflows/test-modeling-commons.yml
@@ -97,22 +97,9 @@ jobs:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ steps.pw-version.outputs.version }}
- # - name: Install Playwright browser
- # if: steps.playwright.outputs.cache-hit != 'true'
- # run: yarn run e2e:install-browser
- # working-directory: apps/modeling-commons-frontend
-
- - name: Install Playwright deps
- run: npx playwright-core install-deps chromium
- working-directory: apps/modeling-commons-frontend
-
- name: Install Playwright browser
- timeout-minutes: 5
- run: |
- npx playwright-core install chromium
- sudo fc-cache -f
- env:
- FONTCONFIG_PATH: /dev/null
+ if: steps.playwright.outputs.cache-hit != 'true'
+ run: yarn run e2e:install-browser
working-directory: apps/modeling-commons-frontend
- name: Build frontend
From e10e813e14bb83d2edc942c3a1bf393dc61a44b2 Mon Sep 17 00:00:00 2001
From: Omar Ibrahim
Date: Wed, 10 Jun 2026 14:10:03 -0500
Subject: [PATCH 014/101] Minor(modeling-commons): Add loading spinner to
Author and Tag filters
---
.../app/components/tag/TagSelectMenu.vue | 2 +-
.../app/components/user/UserSelectMenu.vue | 2 ++
2 files changed, 3 insertions(+), 1 deletion(-)
diff --git a/apps/modeling-commons-frontend/app/components/tag/TagSelectMenu.vue b/apps/modeling-commons-frontend/app/components/tag/TagSelectMenu.vue
index 81d951a1..1a3743fd 100644
--- a/apps/modeling-commons-frontend/app/components/tag/TagSelectMenu.vue
+++ b/apps/modeling-commons-frontend/app/components/tag/TagSelectMenu.vue
@@ -7,7 +7,7 @@
:items="userMenuItems"
icon="i-lucide-hash"
virtualize
- loading-icon="i-lucide-loader"
+ :loading="loading"
multiple
@update:model-value="
emit(
diff --git a/apps/modeling-commons-frontend/app/components/user/UserSelectMenu.vue b/apps/modeling-commons-frontend/app/components/user/UserSelectMenu.vue
index 2454875e..ec645ade 100644
--- a/apps/modeling-commons-frontend/app/components/user/UserSelectMenu.vue
+++ b/apps/modeling-commons-frontend/app/components/user/UserSelectMenu.vue
@@ -6,6 +6,7 @@
placeholder="Author"
:items="userMenuItems"
virtualize
+ :loading="loading"
>
+
From 6f44d3decb76577040d02da1e42ab7f07f4d0065 Mon Sep 17 00:00:00 2001
From: Omar Ibrahim
Date: Wed, 10 Jun 2026 14:29:54 -0500
Subject: [PATCH 015/101] ci(modeling-commons): parameterize build with GitHub
environments
---
.../build-and-push-modeling-commons.yml | 45 +++++++++++--------
.github/workflows/test-modeling-commons.yml | 10 ++++-
2 files changed, 35 insertions(+), 20 deletions(-)
diff --git a/.github/workflows/build-and-push-modeling-commons.yml b/.github/workflows/build-and-push-modeling-commons.yml
index 9368d2c0..b321ec4d 100644
--- a/.github/workflows/build-and-push-modeling-commons.yml
+++ b/.github/workflows/build-and-push-modeling-commons.yml
@@ -22,11 +22,16 @@ on:
- 'yarn.lock'
- '.github/workflows/build-and-push-modeling-commons.yml'
workflow_dispatch:
+ workflow_call:
+ inputs:
+ environment:
+ required: true
+ type: string
jobs:
build-and-push:
runs-on: ubuntu-latest
- environment: netlogo1
+ environment: ${{ inputs.environment || 'modeling-commons-production' }}
permissions:
contents: read
packages: write
@@ -42,28 +47,28 @@ jobs:
- name: Install dependencies
run: yarn run init
env:
- HELIO_SKIP_CHECKS: '1'
- NUXT_TELEMETRY_DISABLED: '1'
+ HELIO_SKIP_CHECKS: ${{ vars.HELIO_SKIP_CHECKS }}
+ NUXT_TELEMETRY_DISABLED: ${{ vars.NUXT_TELEMETRY_DISABLED }}
- name: Build backend
run: yarn workspace modeling-commons-backend fastify:build
env:
- HELIO_HEADLESS: 1
+ HELIO_HEADLESS: ${{ vars.HELIO_HEADLESS }}
- name: Build frontend
working-directory: apps/modeling-commons-frontend
run: yarn nuxt:build
env:
- HELIO_HEADLESS: 1
- NUXT_TELEMETRY_DISABLED: 1
- NODE_OPTIONS: --max-old-space-size=4096
- NUXT_PUBLIC_AUTH_BASE: 'https://beta.modelingcommons.org/api/auth'
- NUXT_PUBLIC_API_BASE: 'https://beta.modelingcommons.org'
- NUXT_PUBLIC_APP_URL: 'https://beta.modelingcommons.org'
- NUXT_STORAGE_BASE_URL: 'https://cdn.modelingcommons.org'
- NUXT_PUBLIC_CDN_URL: 'https://cdn.modelingcommons.org'
- NUXT_PUBLIC_NETLOGO_WEB_URL: 'https://www.netlogoweb.org'
- NUXT_PUBLIC_GA_TRACKING_ID: 'G-VJ4WPV2Q5B'
+ HELIO_HEADLESS: ${{ vars.HELIO_HEADLESS }}
+ NUXT_TELEMETRY_DISABLED: ${{ vars.NUXT_TELEMETRY_DISABLED }}
+ NODE_OPTIONS: ${{ vars.NODE_OPTIONS }}
+ NUXT_PUBLIC_AUTH_BASE: ${{ vars.NUXT_PUBLIC_AUTH_BASE }}
+ NUXT_PUBLIC_API_BASE: ${{ vars.NUXT_PUBLIC_API_BASE }}
+ NUXT_PUBLIC_APP_URL: ${{ vars.NUXT_PUBLIC_APP_URL }}
+ NUXT_STORAGE_BASE_URL: ${{ vars.NUXT_STORAGE_BASE_URL }}
+ NUXT_PUBLIC_CDN_URL: ${{ vars.NUXT_PUBLIC_CDN_URL }}
+ NUXT_PUBLIC_NETLOGO_WEB_URL: ${{ vars.NUXT_PUBLIC_NETLOGO_WEB_URL }}
+ NUXT_PUBLIC_GA_TRACKING_ID: ${{ vars.NUXT_PUBLIC_GA_TRACKING_ID }}
- uses: docker/setup-buildx-action@v3
@@ -89,7 +94,7 @@ jobs:
push: true
tags: |
${{ steps.repo_slug.outputs.result }}/modeling-commons-backend:${{ github.sha }}
- ${{ steps.repo_slug.outputs.result }}/modeling-commons-backend:latest
+ ${{ steps.repo_slug.outputs.result }}/modeling-commons-backend:${{ vars.VARIANT_IMAGE_TAG }}
cache-from: type=gha
cache-to: type=gha,mode=max
@@ -101,15 +106,19 @@ jobs:
push: true
tags: |
${{ steps.repo_slug.outputs.result }}/modeling-commons-frontend:${{ github.sha }}
- ${{ steps.repo_slug.outputs.result }}/modeling-commons-frontend:latest
+ ${{ steps.repo_slug.outputs.result }}/modeling-commons-frontend:${{ vars.VARIANT_IMAGE_TAG }}
cache-from: type=gha
cache-to: type=gha,mode=max
+
restart-deployments:
needs: build-and-push
runs-on: self-hosted
+ environment: ${{ inputs.environment || 'modeling-commons-production' }}
steps:
- name: Restart deployments
+ env:
+ KUBE_NAMESPACE: ${{ vars.KUBE_NAMESPACE }}
run: |
APISERVER=https://kubernetes.default.svc
TOKEN=$(cat /var/run/secrets/kubernetes.io/serviceaccount/token)
@@ -123,5 +132,5 @@ jobs:
-H "Content-Type: application/strategic-merge-patch+json" \
-d "$PATCH" \
--cacert "$CA" \
- "$APISERVER/apis/apps/v1/namespaces/modeling-commons/deployments/$DEPLOY"
- done
\ No newline at end of file
+ "$APISERVER/apis/apps/v1/namespaces/$KUBE_NAMESPACE/deployments/$DEPLOY"
+ done
diff --git a/.github/workflows/test-modeling-commons.yml b/.github/workflows/test-modeling-commons.yml
index 30c5bc4c..86878769 100644
--- a/.github/workflows/test-modeling-commons.yml
+++ b/.github/workflows/test-modeling-commons.yml
@@ -2,7 +2,7 @@ name: Test Modeling Commons
on:
push:
- branches: [main, app/modeling-commons, staging/modeling-commons]
+ branches: [main, staging/modeling-commons]
paths:
- 'apps/modeling-commons-backend/**'
- 'apps/modeling-commons-frontend/**'
@@ -126,4 +126,10 @@ jobs:
- if: always()
run: docker compose -f docker-compose.dev.yml down -v
- working-directory: apps/modeling-commons-backend
\ No newline at end of file
+ working-directory: apps/modeling-commons-backend
+
+ deploy-staging:
+ needs: test
+ uses: ./.github/workflows/build-and-push-modeling-commons.yml
+ with:
+ environment: modeling-commons-staging
\ No newline at end of file
From 2485ffecb64869e260def83c741769354f77c539 Mon Sep 17 00:00:00 2001
From: Omar Ibrahim
Date: Thu, 11 Jun 2026 16:37:34 -0500
Subject: [PATCH 016/101] ci(modeling-commons): cache node_modules in build
workflow
---
.github/workflows/build-and-push-modeling-commons.yml | 10 ++++++++++
1 file changed, 10 insertions(+)
diff --git a/.github/workflows/build-and-push-modeling-commons.yml b/.github/workflows/build-and-push-modeling-commons.yml
index b321ec4d..35862550 100644
--- a/.github/workflows/build-and-push-modeling-commons.yml
+++ b/.github/workflows/build-and-push-modeling-commons.yml
@@ -44,6 +44,16 @@ jobs:
node-version: '24'
cache: yarn
+ - uses: actions/cache@v4
+ with:
+ path: |
+ node_modules
+ apps/*/node_modules
+ packages/*/node_modules
+ key: node-modules-${{ runner.os }}-${{ hashFiles('yarn.lock') }}
+ restore-keys: |
+ node-modules-${{ runner.os }}-
+
- name: Install dependencies
run: yarn run init
env:
From aa12fea3543efc1f5e6ada813686fdcee75902e8 Mon Sep 17 00:00:00 2001
From: Omar Ibrahim
Date: Thu, 11 Jun 2026 16:38:41 -0500
Subject: [PATCH 017/101] ci(modeling-commons): run frontend e2e tests
separately to avoid timeout
---
.github/workflows/test-modeling-commons.yml | 14 +++++++++++---
apps/modeling-commons-frontend/package.json | 1 +
.../tests/e2e/auth-signup-login.test.ts | 6 +++---
3 files changed, 15 insertions(+), 6 deletions(-)
diff --git a/.github/workflows/test-modeling-commons.yml b/.github/workflows/test-modeling-commons.yml
index 86878769..f101c8b6 100644
--- a/.github/workflows/test-modeling-commons.yml
+++ b/.github/workflows/test-modeling-commons.yml
@@ -116,10 +116,18 @@ jobs:
PORT: '3005'
NODE_OPTIONS: --dns-result-order=ipv4first
+ # Split the suite so e2e doesn't share the runner's 2 cores with the other
+ # five in-process projects (plus Chromium).
+ # -- Omar Ibrahim, Jun 11 26
+ - name: Run in-process frontend tests
+ run: yarn run test:in-process
+ working-directory: apps/modeling-commons-frontend
+
# E2E runs against the already-built server (E2E_HOST), so the e2e project
- # skips its per-file Nuxt build — the non-e2e projects are unaffected.
- - name: Run frontend tests
- run: yarn run test:all
+ # skips its per-file Nuxt build.
+ # -- Omar Ibrahim, Jun 11 26
+ - name: Run frontend e2e tests
+ run: yarn run test:e2e
working-directory: apps/modeling-commons-frontend
env:
E2E_HOST: http://localhost:3005
diff --git a/apps/modeling-commons-frontend/package.json b/apps/modeling-commons-frontend/package.json
index 64827cbc..72c090c3 100644
--- a/apps/modeling-commons-frontend/package.json
+++ b/apps/modeling-commons-frontend/package.json
@@ -19,6 +19,7 @@
"nuxt:prepare": "nuxt prepare",
"test": "vitest run --project=unit --project=components",
"test:all": "vitest run",
+ "test:in-process": "vitest run --project=unit --project=ssr --project=components --project=nuxt --project=bff",
"test:watch": "vitest",
"test:unit": "vitest run --project=unit",
"test:ssr": "vitest run --project=ssr",
diff --git a/apps/modeling-commons-frontend/tests/e2e/auth-signup-login.test.ts b/apps/modeling-commons-frontend/tests/e2e/auth-signup-login.test.ts
index 973d839c..3464dd3d 100644
--- a/apps/modeling-commons-frontend/tests/e2e/auth-signup-login.test.ts
+++ b/apps/modeling-commons-frontend/tests/e2e/auth-signup-login.test.ts
@@ -44,12 +44,12 @@ describe("auth: signup + login", async () => {
// The auth flow either bounces back to /verify-email or surfaces a toast.
await Promise.race([
- page.waitForURL(/\/verify-email/, { timeout: 15_000 }),
+ page.waitForURL(/\/verify-email/, { timeout: 30_000 }),
page
.locator("[data-slot='title']")
.filter({ hasText: /verify your email|Login failed/i })
.first()
- .waitFor({ timeout: 15_000 }),
+ .waitFor({ timeout: 30_000 }),
]);
expect(page.url()).not.toMatch(/\/models\/?$/);
@@ -69,7 +69,7 @@ describe("auth: signup + login", async () => {
.locator("[data-slot='title']")
.filter({ hasText: /Login failed|verify your email/i })
.first()
- .waitFor({ timeout: 15_000 });
+ .waitFor({ timeout: 30_000 });
expect(page.url()).not.toMatch(/\/models\/?$/);
await page.close();
From 754cdd13ed03dec7a459152d90c85bf390aecc6e Mon Sep 17 00:00:00 2001
From: Omar Ibrahim
Date: Thu, 11 Jun 2026 20:00:34 -0500
Subject: [PATCH 018/101] test(modeling-commons): fix flaky auth e2e by gating
on hydration
---
.github/workflows/test-modeling-commons.yml | 11 +++
apps/modeling-commons-frontend/.gitignore | 3 +
.../tests/e2e/auth-signup-login.test.ts | 69 +++++++++++--------
.../tests/e2e/helpers/auth.ts | 6 +-
.../tests/e2e/helpers/debug.ts | 21 ++++++
.../tests/e2e/helpers/form.ts | 11 +--
.../tests/e2e/helpers/nav.ts | 11 +++
7 files changed, 92 insertions(+), 40 deletions(-)
create mode 100644 apps/modeling-commons-frontend/tests/e2e/helpers/debug.ts
create mode 100644 apps/modeling-commons-frontend/tests/e2e/helpers/nav.ts
diff --git a/.github/workflows/test-modeling-commons.yml b/.github/workflows/test-modeling-commons.yml
index f101c8b6..5e702f4e 100644
--- a/.github/workflows/test-modeling-commons.yml
+++ b/.github/workflows/test-modeling-commons.yml
@@ -131,6 +131,17 @@ jobs:
working-directory: apps/modeling-commons-frontend
env:
E2E_HOST: http://localhost:3005
+ E2E_ARTIFACT_DIR: ${{ runner.temp }}/e2e-artifacts
+
+ # Failure-only net: on a failed e2e run, upload the screenshot + HTML that
+ # dumpOnFailure captured at the point of failure.
+ - name: Upload e2e failure artifacts
+ if: failure()
+ uses: actions/upload-artifact@v4
+ with:
+ name: e2e-failure-artifacts
+ if-no-files-found: ignore
+ path: ${{ runner.temp }}/e2e-artifacts
- if: always()
run: docker compose -f docker-compose.dev.yml down -v
diff --git a/apps/modeling-commons-frontend/.gitignore b/apps/modeling-commons-frontend/.gitignore
index 48fcd8fa..6e68b77a 100644
--- a/apps/modeling-commons-frontend/.gitignore
+++ b/apps/modeling-commons-frontend/.gitignore
@@ -17,3 +17,6 @@ logs
.DS_Store
.fleet
.idea
+
+# e2e diagnostic artifacts (local)
+tests/e2e/.artifacts/
diff --git a/apps/modeling-commons-frontend/tests/e2e/auth-signup-login.test.ts b/apps/modeling-commons-frontend/tests/e2e/auth-signup-login.test.ts
index 3464dd3d..4559a88e 100644
--- a/apps/modeling-commons-frontend/tests/e2e/auth-signup-login.test.ts
+++ b/apps/modeling-commons-frontend/tests/e2e/auth-signup-login.test.ts
@@ -7,10 +7,12 @@
// relying on positional locators or class-string hooks.
import { describe, expect, it } from "vitest";
-import { createPage, url } from "@nuxt/test-utils/e2e";
+import { createPage } from "@nuxt/test-utils/e2e";
import { e2eSetup } from "./setup";
import { buildRandomUser, signUpRandomUser } from "./helpers/auth";
import { fillField } from "./helpers/form";
+import { gotoHydrated } from "./helpers/nav";
+import { dumpOnFailure } from "./helpers/debug";
describe("auth: signup + login", async () => {
await e2eSetup();
@@ -34,45 +36,54 @@ describe("auth: signup + login", async () => {
});
it("blocks login for a freshly signed-up but unverified user", async () => {
+ const label = "unverified-login";
const page = await createPage();
- const user = await signUpRandomUser(page);
+ try {
+ const user = await signUpRandomUser(page);
- await page.goto(url("/login"));
- await fillField(page.getByLabel("Email"), user.email);
- await fillField(page.getByLabel("Password", { exact: true }), user.password);
- await page.getByRole("button", { name: "Log In" }).click();
+ await gotoHydrated(page, "/login");
+ await fillField(page.getByLabel("Email"), user.email);
+ await fillField(page.getByLabel("Password", { exact: true }), user.password);
- // The auth flow either bounces back to /verify-email or surfaces a toast.
- await Promise.race([
- page.waitForURL(/\/verify-email/, { timeout: 30_000 }),
- page
- .locator("[data-slot='title']")
- .filter({ hasText: /verify your email|Login failed/i })
- .first()
- .waitFor({ timeout: 30_000 }),
- ]);
+ // Assert the backend rejects the unverified login, then that the client
+ // bounces to the verify-email screen rather than logging in.
+ const signIn = page.waitForResponse((r) => r.url().includes("/sign-in/email"), {
+ timeout: 30_000,
+ });
+ await page.getByRole("button", { name: "Log In" }).click();
+ const response = await signIn;
- expect(page.url()).not.toMatch(/\/models\/?$/);
- await page.close();
+ expect(response.ok()).toBe(false);
+ await page.waitForURL(/\/verify-email/, { timeout: 30_000 });
+ expect(page.url()).not.toMatch(/\/models\/?$/);
+ await page.close();
+ } catch (err) {
+ await dumpOnFailure(page, label, err);
+ }
});
it("rejects login with bogus credentials", async () => {
+ const label = "bogus-login";
const page = await createPage();
- await page.goto(url("/login"));
+ try {
+ await gotoHydrated(page, "/login");
- const fake = buildRandomUser();
- await fillField(page.getByLabel("Email"), fake.email);
- await fillField(page.getByLabel("Password", { exact: true }), fake.password);
- await page.getByRole("button", { name: "Log In" }).click();
+ const fake = buildRandomUser();
+ await fillField(page.getByLabel("Email"), fake.email);
+ await fillField(page.getByLabel("Password", { exact: true }), fake.password);
- await page
- .locator("[data-slot='title']")
- .filter({ hasText: /Login failed|verify your email/i })
- .first()
- .waitFor({ timeout: 30_000 });
+ const signIn = page.waitForResponse((r) => r.url().includes("/sign-in/email"), {
+ timeout: 30_000,
+ });
+ await page.getByRole("button", { name: "Log In" }).click();
+ const response = await signIn;
- expect(page.url()).not.toMatch(/\/models\/?$/);
- await page.close();
+ expect(response.ok()).toBe(false);
+ expect(page.url()).not.toMatch(/\/models\/?$/);
+ await page.close();
+ } catch (err) {
+ await dumpOnFailure(page, label, err);
+ }
});
it.todo(
diff --git a/apps/modeling-commons-frontend/tests/e2e/helpers/auth.ts b/apps/modeling-commons-frontend/tests/e2e/helpers/auth.ts
index 8cbe2d88..313cae3c 100644
--- a/apps/modeling-commons-frontend/tests/e2e/helpers/auth.ts
+++ b/apps/modeling-commons-frontend/tests/e2e/helpers/auth.ts
@@ -1,6 +1,6 @@
import type { Page } from "playwright-core";
-import { url } from "@nuxt/test-utils/e2e";
import { fillField } from "./form";
+import { gotoHydrated } from "./nav";
export interface SignedUpUser {
email: string;
@@ -21,7 +21,7 @@ export function buildRandomUser(): SignedUpUser {
export async function signUpRandomUser(page: Page): Promise {
const user = buildRandomUser();
- await page.goto(url("/signup"));
+ await gotoHydrated(page, "/signup");
await fillField(page.getByLabel("Name"), user.name);
await fillField(page.getByLabel("Email"), user.email);
@@ -38,7 +38,7 @@ export async function signIn(
page: Page,
creds: { email: string; password: string },
): Promise {
- await page.goto(url("/login"));
+ await gotoHydrated(page, "/login");
await fillField(page.getByLabel("Email"), creds.email);
await fillField(page.getByLabel("Password", { exact: true }), creds.password);
diff --git a/apps/modeling-commons-frontend/tests/e2e/helpers/debug.ts b/apps/modeling-commons-frontend/tests/e2e/helpers/debug.ts
new file mode 100644
index 00000000..0556b3d7
--- /dev/null
+++ b/apps/modeling-commons-frontend/tests/e2e/helpers/debug.ts
@@ -0,0 +1,21 @@
+import { mkdir, writeFile } from "node:fs/promises";
+import { join } from "node:path";
+import type { Page } from "playwright-core";
+
+// Failure-only net for the e2e auth tests: on a thrown assertion, capture a
+// screenshot + HTML so CI artifacts show the page state at the moment of
+// failure. No-op on green runs.
+const ARTIFACT_DIR = process.env.E2E_ARTIFACT_DIR ?? join(process.cwd(), "tests/e2e/.artifacts");
+
+export async function dumpOnFailure(page: Page, label: string, err: unknown): Promise {
+ try {
+ await mkdir(ARTIFACT_DIR, { recursive: true });
+ const shot = join(ARTIFACT_DIR, `${label}.png`);
+ await page.screenshot({ path: shot, fullPage: true });
+ await writeFile(join(ARTIFACT_DIR, `${label}.html`), await page.content());
+ console.log(`[e2e:${label}] FAILURE url=${page.url()} :: saved ${shot}`);
+ } catch (dumpErr) {
+ console.log(`[e2e:${label}] dump failed: ${(dumpErr as Error).message}`);
+ }
+ throw err;
+}
diff --git a/apps/modeling-commons-frontend/tests/e2e/helpers/form.ts b/apps/modeling-commons-frontend/tests/e2e/helpers/form.ts
index 2a77d4d2..b196a268 100644
--- a/apps/modeling-commons-frontend/tests/e2e/helpers/form.ts
+++ b/apps/modeling-commons-frontend/tests/e2e/helpers/form.ts
@@ -2,15 +2,10 @@ import type { Locator } from "playwright-core";
import { expect } from "vitest";
/**
- * Fill an input so the value survives Nuxt's SSR hydration.
+ * Fill an input, re-filling until the value sticks.
*
- * Right after navigation, Vue hydrates the server-rendered markup and re-renders
- * the inputs. If Playwright types into a field before hydration settles, the
- * re-render silently clears it — this reliably wiped the *first* field filled on
- * a freshly loaded page (e.g. "Name" on /signup, "Email" on /login), so the form
- * failed validation, never submitted, and `waitForURL` timed out. The result was
- * an intermittent failure that looked like a backend/CORS problem but was a
- * client-side race. Re-fill until the value sticks.
+ * Navigate with `gotoHydrated` first so the field is interactive; this only
+ * guards against a transient re-render flushing the value mid-fill.
*/
export async function fillField(locator: Locator, value: string): Promise {
await locator.waitFor({ state: "visible" });
diff --git a/apps/modeling-commons-frontend/tests/e2e/helpers/nav.ts b/apps/modeling-commons-frontend/tests/e2e/helpers/nav.ts
new file mode 100644
index 00000000..be93a4d2
--- /dev/null
+++ b/apps/modeling-commons-frontend/tests/e2e/helpers/nav.ts
@@ -0,0 +1,11 @@
+import type { Page } from "playwright-core";
+import { url, waitForHydration } from "@nuxt/test-utils/e2e";
+
+// Navigate and wait for hydration before returning, so fills and clicks land on
+// a live Vue form instead of inert SSR markup (a known cause of flaky auth
+// tests). Uses @nuxt/test-utils' own waitForHydration primitive.
+export async function gotoHydrated(page: Page, path: string): Promise {
+ const target = url(path);
+ await page.goto(target);
+ await waitForHydration(page, target, "hydration");
+}
From fe70aedcd26d0196c1f472dc9f065cdc640e69bb Mon Sep 17 00:00:00 2001
From: Omar Ibrahim
Date: Thu, 11 Jun 2026 21:10:56 -0500
Subject: [PATCH 019/101] ci(modeling-commons): use correct turbo cache
---
.github/workflows/test-modeling-commons.yml | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/.github/workflows/test-modeling-commons.yml b/.github/workflows/test-modeling-commons.yml
index 5e702f4e..922c399f 100644
--- a/.github/workflows/test-modeling-commons.yml
+++ b/.github/workflows/test-modeling-commons.yml
@@ -43,11 +43,11 @@ jobs:
- uses: actions/cache@v4
with:
- path: node_modules/.cache/turbo
- key: turbo-${{ runner.os }}-${{ hashFiles('yarn.lock') }}-${{ github.sha }}
+ path: .turbo
+ key: ${{ runner.os }}-turbo-${{ hashFiles('yarn.lock') }}-${{ github.sha }}
restore-keys: |
- turbo-${{ runner.os }}-${{ hashFiles('yarn.lock') }}-
- turbo-${{ runner.os }}-
+ ${{ runner.os }}-turbo-${{ hashFiles('yarn.lock') }}-
+ ${{ runner.os }}-turbo-
- uses: actions/cache@v4
with:
From 2407e24f22444adc6fd36479803ca21245368b97 Mon Sep 17 00:00:00 2001
From: Omar Ibrahim
Date: Mon, 15 Jun 2026 12:34:43 -0500
Subject: [PATCH 020/101] chore: update yarn.lock
---
yarn.lock | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/yarn.lock b/yarn.lock
index a688ade5..a61c52b2 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -18198,10 +18198,10 @@ playwright-core@^1.56.0:
resolved "https://registry.yarnpkg.com/playwright-core/-/playwright-core-1.56.1.tgz#24a66481e5cd33a045632230aa2c4f0cb6b1db3d"
integrity sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ==
-playwright-core@^1.59.1:
- version "1.59.1"
- resolved "https://registry.yarnpkg.com/playwright-core/-/playwright-core-1.59.1.tgz#d8a2b28bcb8f2bd08ef3df93b02ae83c813244b2"
- integrity sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==
+playwright-core@^1.60.0:
+ version "1.60.0"
+ resolved "https://registry.yarnpkg.com/playwright-core/-/playwright-core-1.60.0.tgz#24e0d9cc4730713db5dffcace29b5e4696b1907a"
+ integrity sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==
plimit-lit@^1.2.6:
version "1.6.1"
From b5729003311b80e693c348e14b487ddea9324618 Mon Sep 17 00:00:00 2001
From: Omar Ibrahim
Date: Mon, 15 Jun 2026 12:35:03 -0500
Subject: [PATCH 021/101] ci(modeling-commons): source env vars in frontend
docker from ci
---
.../build-and-push-modeling-commons.yml | 8 ++++++
apps/modeling-commons-frontend/Dockerfile | 25 +++++++++++++------
2 files changed, 26 insertions(+), 7 deletions(-)
diff --git a/.github/workflows/build-and-push-modeling-commons.yml b/.github/workflows/build-and-push-modeling-commons.yml
index 35862550..2c426a0b 100644
--- a/.github/workflows/build-and-push-modeling-commons.yml
+++ b/.github/workflows/build-and-push-modeling-commons.yml
@@ -117,6 +117,14 @@ jobs:
tags: |
${{ steps.repo_slug.outputs.result }}/modeling-commons-frontend:${{ github.sha }}
${{ steps.repo_slug.outputs.result }}/modeling-commons-frontend:${{ vars.VARIANT_IMAGE_TAG }}
+ build-args: |
+ NUXT_PUBLIC_API_BASE=${{ vars.NUXT_PUBLIC_API_BASE }}
+ NUXT_PUBLIC_AUTH_BASE=${{ vars.NUXT_PUBLIC_AUTH_BASE }}
+ NUXT_PUBLIC_APP_URL=${{ vars.NUXT_PUBLIC_APP_URL }}
+ NUXT_STORAGE_BASE_URL=${{ vars.NUXT_STORAGE_BASE_URL }}
+ NUXT_PUBLIC_CDN_URL=${{ vars.NUXT_PUBLIC_CDN_URL }}
+ NUXT_PUBLIC_NETLOGO_WEB_URL=${{ vars.NUXT_PUBLIC_NETLOGO_WEB_URL }}
+ NUXT_PUBLIC_GA_TRACKING_ID=${{ vars.NUXT_PUBLIC_GA_TRACKING_ID }}
cache-from: type=gha
cache-to: type=gha,mode=max
diff --git a/apps/modeling-commons-frontend/Dockerfile b/apps/modeling-commons-frontend/Dockerfile
index b98658a2..4aab3ef5 100644
--- a/apps/modeling-commons-frontend/Dockerfile
+++ b/apps/modeling-commons-frontend/Dockerfile
@@ -3,13 +3,24 @@ FROM node:24-alpine
RUN apk add --no-cache dumb-init
ENV NODE_ENV=production HOST=0.0.0.0 NITRO_PORT=3005
-ENV NUXT_PUBLIC_API_BASE="https://beta.modelingcommons.org"
-ENV NUXT_PUBLIC_AUTH_BASE="https://beta.modelingcommons.org/api/auth"
-ENV NUXT_PUBLIC_APP_URL="https://beta.modelingcommons.org"
-ENV NUXT_STORAGE_BASE_URL="https://cdn.modelingcommons.org"
-ENV NUXT_PUBLIC_CDN_URL="https://cdn.modelingcommons.org"
-ENV NUXT_PUBLIC_NETLOGO_WEB_URL="https://www.netlogoweb.org"
-ENV NUXT_PUBLIC_GA_TRACKING_ID="G-VJ4WPV2Q5B"
+
+# Sensible default values
+ARG NUXT_PUBLIC_API_BASE="https://beta.modelingcommons.org"
+ARG NUXT_PUBLIC_AUTH_BASE="https://beta.modelingcommons.org/api/auth"
+ARG NUXT_PUBLIC_APP_URL="https://beta.modelingcommons.org"
+ARG NUXT_STORAGE_BASE_URL="https://cdn.modelingcommons.org"
+ARG NUXT_PUBLIC_CDN_URL="https://cdn.modelingcommons.org"
+ARG NUXT_PUBLIC_NETLOGO_WEB_URL="https://www.netlogoweb.org"
+ARG NUXT_PUBLIC_GA_TRACKING_ID="G-VJ4WPV2Q5B"
+
+# Pass build args as environment variables to be used in the application
+ENV NUXT_PUBLIC_API_BASE=${NUXT_PUBLIC_API_BASE}
+ENV NUXT_PUBLIC_AUTH_BASE=${NUXT_PUBLIC_AUTH_BASE}
+ENV NUXT_PUBLIC_APP_URL=${NUXT_PUBLIC_APP_URL}
+ENV NUXT_STORAGE_BASE_URL=${NUXT_STORAGE_BASE_URL}
+ENV NUXT_PUBLIC_CDN_URL=${NUXT_PUBLIC_CDN_URL}
+ENV NUXT_PUBLIC_NETLOGO_WEB_URL=${NUXT_PUBLIC_NETLOGO_WEB_URL}
+ENV NUXT_PUBLIC_GA_TRACKING_ID=${NUXT_PUBLIC_GA_TRACKING_ID}
RUN addgroup -g 1001 -S nodejs && adduser -S nuxt -u 1001 -G nodejs
WORKDIR /app
From b848dc774aae70749b495a0d2a7ea94964398d75 Mon Sep 17 00:00:00 2001
From: Omar Ibrahim
Date: Mon, 15 Jun 2026 12:35:30 -0500
Subject: [PATCH 022/101] ci(modeling-commons): use a Debian image for frontend
---
apps/modeling-commons-frontend/Dockerfile | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/apps/modeling-commons-frontend/Dockerfile b/apps/modeling-commons-frontend/Dockerfile
index 4aab3ef5..784363ed 100644
--- a/apps/modeling-commons-frontend/Dockerfile
+++ b/apps/modeling-commons-frontend/Dockerfile
@@ -1,6 +1,7 @@
# syntax=docker/dockerfile:1
-FROM node:24-alpine
-RUN apk add --no-cache dumb-init
+FROM node:24-slim
+RUN apt-get update && apt-get install -y --no-install-recommends dumb-init \
+ && rm -rf /var/lib/apt/lists/*
ENV NODE_ENV=production HOST=0.0.0.0 NITRO_PORT=3005
From f4cdadce48e67f00b0d7987d2786ca73194bfcc9 Mon Sep 17 00:00:00 2001
From: Omar Ibrahim
Date: Mon, 15 Jun 2026 13:03:17 -0500
Subject: [PATCH 023/101] ci(modeling-commons): add Turbo cache to build
workflow
---
.github/workflows/build-and-push-modeling-commons.yml | 8 ++++++++
1 file changed, 8 insertions(+)
diff --git a/.github/workflows/build-and-push-modeling-commons.yml b/.github/workflows/build-and-push-modeling-commons.yml
index 2c426a0b..224fd67b 100644
--- a/.github/workflows/build-and-push-modeling-commons.yml
+++ b/.github/workflows/build-and-push-modeling-commons.yml
@@ -44,6 +44,14 @@ jobs:
node-version: '24'
cache: yarn
+ - uses: actions/cache@v4
+ with:
+ path: .turbo
+ key: ${{ runner.os }}-turbo-${{ hashFiles('yarn.lock') }}-${{ github.sha }}
+ restore-keys: |
+ ${{ runner.os }}-turbo-${{ hashFiles('yarn.lock') }}-
+ ${{ runner.os }}-turbo-
+
- uses: actions/cache@v4
with:
path: |
From d27804a976b08eff08e574fc4c4f725899ffab2c Mon Sep 17 00:00:00 2001
From: Omar Ibrahim
Date: Mon, 15 Jun 2026 13:04:08 -0500
Subject: [PATCH 024/101] minor(modeling-commons): enable IPX as image provider
---
apps/modeling-commons-frontend/nuxt.config.ts | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/apps/modeling-commons-frontend/nuxt.config.ts b/apps/modeling-commons-frontend/nuxt.config.ts
index e38139ab..16d28554 100644
--- a/apps/modeling-commons-frontend/nuxt.config.ts
+++ b/apps/modeling-commons-frontend/nuxt.config.ts
@@ -176,12 +176,13 @@ export default defineNuxtConfig({
image: {
domains: imageDomains,
- provider: "none",
+ provider: "ipx",
format: ["avif", "webp", "jpeg"],
ipx: {
// Avoid exposing name of internal binary
// -Omar Ibrahim, Apr 20 26
baseURL: "/_images",
+ maxAge: 60 * 60 * 24 * 30,
},
},
From c112ac413daaddd3f1d3bc5af57aabf6e33f1fa9 Mon Sep 17 00:00:00 2001
From: Omar Ibrahim
Date: Mon, 15 Jun 2026 16:11:42 -0500
Subject: [PATCH 025/101] Bugfix(modeling-commons): fix sharp/IPX native binary
in frontend image
Consolidates the IPX hardening work:
- Bypass IPX for SVG images.
- Use Debian useradd/groupadd in the frontend image.
- Pin sharp via root resolutions.
- Reinstall sharp in the runtime image as a flat tree: Nitro symlinks the
native packages to @version-suffixed dirs, breaking the $ORIGIN-relative
rpath the sharp binding uses to load libvips (ERR_DLOPEN_FAILED).
---
apps/modeling-commons-frontend/Dockerfile | 16 +-
.../app/pages/index.vue | 2 +-
package.json | 3 +-
yarn.lock | 340 +-----------------
4 files changed, 22 insertions(+), 339 deletions(-)
diff --git a/apps/modeling-commons-frontend/Dockerfile b/apps/modeling-commons-frontend/Dockerfile
index 784363ed..cbaafb82 100644
--- a/apps/modeling-commons-frontend/Dockerfile
+++ b/apps/modeling-commons-frontend/Dockerfile
@@ -23,9 +23,23 @@ ENV NUXT_PUBLIC_CDN_URL=${NUXT_PUBLIC_CDN_URL}
ENV NUXT_PUBLIC_NETLOGO_WEB_URL=${NUXT_PUBLIC_NETLOGO_WEB_URL}
ENV NUXT_PUBLIC_GA_TRACKING_ID=${NUXT_PUBLIC_GA_TRACKING_ID}
-RUN addgroup -g 1001 -S nodejs && adduser -S nuxt -u 1001 -G nodejs
+RUN groupadd -r -g 1001 nodejs && useradd -r -u 1001 -g nodejs -s /usr/sbin/nologin nuxt
WORKDIR /app
COPY .output .output
+
+# Nitro symlinks sharp's native packages to @version-suffixed dirs under
+# .output/.nitro, which breaks the $ORIGIN-relative rpath the sharp binding uses
+# to find libvips at load time (ERR_DLOPEN_FAILED: libvips-cpp.so). Reinstall
+# sharp (same traced version) as a flat tree so the rpath resolves.
+RUN SHARP_VERSION="$(node -p "require('./.output/server/node_modules/sharp/package.json').version")" \
+ && npm install --no-save --no-audit --no-fund \
+ --os=linux --cpu="$(node -p process.arch)" --libc=glibc \
+ --prefix /tmp/sharp "sharp@${SHARP_VERSION}" \
+ && rm -rf .output/server/node_modules/sharp .output/server/node_modules/@img \
+ && cp -R /tmp/sharp/node_modules/sharp .output/server/node_modules/sharp \
+ && cp -R /tmp/sharp/node_modules/@img .output/server/node_modules/@img \
+ && rm -rf /tmp/sharp
+
USER nuxt
EXPOSE 3005
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
diff --git a/apps/modeling-commons-frontend/app/pages/index.vue b/apps/modeling-commons-frontend/app/pages/index.vue
index 2de87030..c622d5ae 100644
--- a/apps/modeling-commons-frontend/app/pages/index.vue
+++ b/apps/modeling-commons-frontend/app/pages/index.vue
@@ -31,7 +31,7 @@
- Powered by
+ Powered by
diff --git a/package.json b/package.json
index b51b0671..889c32d9 100644
--- a/package.json
+++ b/package.json
@@ -91,6 +91,7 @@
"node-forge": ">=1.3.2",
"@internationalized/date": "3.12.1",
"@tailwindcss/postcss": "^4.3.0",
- "tailwind-merge": "^3.3.1"
+ "tailwind-merge": "^3.3.1",
+ "sharp": "0.34.5"
}
}
\ No newline at end of file
diff --git a/yarn.lock b/yarn.lock
index a61c52b2..e3043544 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1544,7 +1544,7 @@
"@emnapi/wasi-threads" "1.1.0"
tslib "^2.4.0"
-"@emnapi/runtime@^1.10.0", "@emnapi/runtime@^1.2.0":
+"@emnapi/runtime@^1.10.0":
version "1.10.0"
resolved "https://registry.yarnpkg.com/@emnapi/runtime/-/runtime-1.10.0.tgz#4b260c0d3534204e98c6110b8db1a987d26ec87c"
integrity sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==
@@ -3021,20 +3021,6 @@
resolved "https://registry.yarnpkg.com/@img/colour/-/colour-1.0.0.tgz#d2fabb223455a793bf3bf9c70de3d28526aa8311"
integrity sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==
-"@img/sharp-darwin-arm64@0.33.5":
- version "0.33.5"
- resolved "https://registry.yarnpkg.com/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz#ef5b5a07862805f1e8145a377c8ba6e98813ca08"
- integrity sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==
- optionalDependencies:
- "@img/sharp-libvips-darwin-arm64" "1.0.4"
-
-"@img/sharp-darwin-arm64@0.34.4":
- version "0.34.4"
- resolved "https://registry.yarnpkg.com/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.4.tgz#8a0dcac9e621ff533fbf2e830f6a977b38d67a0c"
- integrity sha512-sitdlPzDVyvmINUdJle3TNHl+AG9QcwiAMsXmccqsCOMZNIdW2/7S26w0LyU8euiLVzFBL3dXPwVCq/ODnf2vA==
- optionalDependencies:
- "@img/sharp-libvips-darwin-arm64" "1.2.3"
-
"@img/sharp-darwin-arm64@0.34.5":
version "0.34.5"
resolved "https://registry.yarnpkg.com/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz#6e0732dcade126b6670af7aa17060b926835ea86"
@@ -3042,20 +3028,6 @@
optionalDependencies:
"@img/sharp-libvips-darwin-arm64" "1.2.4"
-"@img/sharp-darwin-x64@0.33.5":
- version "0.33.5"
- resolved "https://registry.yarnpkg.com/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz#e03d3451cd9e664faa72948cc70a403ea4063d61"
- integrity sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==
- optionalDependencies:
- "@img/sharp-libvips-darwin-x64" "1.0.4"
-
-"@img/sharp-darwin-x64@0.34.4":
- version "0.34.4"
- resolved "https://registry.yarnpkg.com/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.4.tgz#0ba2bd9dbf07f7300fab73305b787e66156f7752"
- integrity sha512-rZheupWIoa3+SOdF/IcUe1ah4ZDpKBGWcsPX6MT0lYniH9micvIU7HQkYTfrx5Xi8u+YqwLtxC/3vl8TQN6rMg==
- optionalDependencies:
- "@img/sharp-libvips-darwin-x64" "1.2.3"
-
"@img/sharp-darwin-x64@0.34.5":
version "0.34.5"
resolved "https://registry.yarnpkg.com/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz#19bc1dd6eba6d5a96283498b9c9f401180ee9c7b"
@@ -3063,71 +3035,26 @@
optionalDependencies:
"@img/sharp-libvips-darwin-x64" "1.2.4"
-"@img/sharp-libvips-darwin-arm64@1.0.4":
- version "1.0.4"
- resolved "https://registry.yarnpkg.com/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz#447c5026700c01a993c7804eb8af5f6e9868c07f"
- integrity sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==
-
-"@img/sharp-libvips-darwin-arm64@1.2.3":
- version "1.2.3"
- resolved "https://registry.yarnpkg.com/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.3.tgz#f43c9aa3b74fd307e4318da63ebbe0ed4c34e744"
- integrity sha512-QzWAKo7kpHxbuHqUC28DZ9pIKpSi2ts2OJnoIGI26+HMgq92ZZ4vk8iJd4XsxN+tYfNJxzH6W62X5eTcsBymHw==
-
"@img/sharp-libvips-darwin-arm64@1.2.4":
version "1.2.4"
resolved "https://registry.yarnpkg.com/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz#2894c0cb87d42276c3889942e8e2db517a492c43"
integrity sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==
-"@img/sharp-libvips-darwin-x64@1.0.4":
- version "1.0.4"
- resolved "https://registry.yarnpkg.com/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz#e0456f8f7c623f9dbfbdc77383caa72281d86062"
- integrity sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==
-
-"@img/sharp-libvips-darwin-x64@1.2.3":
- version "1.2.3"
- resolved "https://registry.yarnpkg.com/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.3.tgz#c42ff786d4a1f42ef8929dba4a989dd5df6417f0"
- integrity sha512-Ju+g2xn1E2AKO6YBhxjj+ACcsPQRHT0bhpglxcEf+3uyPY+/gL8veniKoo96335ZaPo03bdDXMv0t+BBFAbmRA==
-
"@img/sharp-libvips-darwin-x64@1.2.4":
version "1.2.4"
resolved "https://registry.yarnpkg.com/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz#e63681f4539a94af9cd17246ed8881734386f8cc"
integrity sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==
-"@img/sharp-libvips-linux-arm64@1.0.4":
- version "1.0.4"
- resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz#979b1c66c9a91f7ff2893556ef267f90ebe51704"
- integrity sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==
-
-"@img/sharp-libvips-linux-arm64@1.2.3":
- version "1.2.3"
- resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.3.tgz#c9073e5c4b629ee417f777db21c552910d84ed77"
- integrity sha512-I4RxkXU90cpufazhGPyVujYwfIm9Nk1QDEmiIsaPwdnm013F7RIceaCc87kAH+oUB1ezqEvC6ga4m7MSlqsJvQ==
-
"@img/sharp-libvips-linux-arm64@1.2.4":
version "1.2.4"
resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz#b1b288b36864b3bce545ad91fa6dadcf1a4ad318"
integrity sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==
-"@img/sharp-libvips-linux-arm@1.0.5":
- version "1.0.5"
- resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz#99f922d4e15216ec205dcb6891b721bfd2884197"
- integrity sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==
-
-"@img/sharp-libvips-linux-arm@1.2.3":
- version "1.2.3"
- resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.3.tgz#3cbc333fd6b8f224a14d69b03a1dd11df897c799"
- integrity sha512-x1uE93lyP6wEwGvgAIV0gP6zmaL/a0tGzJs/BIDDG0zeBhMnuUPm7ptxGhUbcGs4okDJrk4nxgrmxpib9g6HpA==
-
"@img/sharp-libvips-linux-arm@1.2.4":
version "1.2.4"
resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz#b9260dd1ebe6f9e3bdbcbdcac9d2ac125f35852d"
integrity sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==
-"@img/sharp-libvips-linux-ppc64@1.2.3":
- version "1.2.3"
- resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.3.tgz#68e0e0076299f43d838468675674fabcc7161d16"
- integrity sha512-Y2T7IsQvJLMCBM+pmPbM3bKT/yYJvVtLJGfCs4Sp95SjvnFIjynbjzsa7dY1fRJX45FTSfDksbTp6AGWudiyCg==
-
"@img/sharp-libvips-linux-ppc64@1.2.4":
version "1.2.4"
resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz#4b83ecf2a829057222b38848c7b022e7b4d07aa7"
@@ -3138,80 +3065,26 @@
resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz#880b4678009e5a2080af192332b00b0aaf8a48de"
integrity sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==
-"@img/sharp-libvips-linux-s390x@1.0.4":
- version "1.0.4"
- resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz#f8a5eb1f374a082f72b3f45e2fb25b8118a8a5ce"
- integrity sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==
-
-"@img/sharp-libvips-linux-s390x@1.2.3":
- version "1.2.3"
- resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.3.tgz#7da9ab11a50c0ca905979f0aae14a4ccffab27b2"
- integrity sha512-RgWrs/gVU7f+K7P+KeHFaBAJlNkD1nIZuVXdQv6S+fNA6syCcoboNjsV2Pou7zNlVdNQoQUpQTk8SWDHUA3y/w==
-
"@img/sharp-libvips-linux-s390x@1.2.4":
version "1.2.4"
resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz#74f343c8e10fad821b38f75ced30488939dc59ec"
integrity sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==
-"@img/sharp-libvips-linux-x64@1.0.4":
- version "1.0.4"
- resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz#d4c4619cdd157774906e15770ee119931c7ef5e0"
- integrity sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==
-
-"@img/sharp-libvips-linux-x64@1.2.3":
- version "1.2.3"
- resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.3.tgz#3b162d6b190cf77926819040e09fb15eec42135e"
- integrity sha512-3JU7LmR85K6bBiRzSUc/Ff9JBVIFVvq6bomKE0e63UXGeRw2HPVEjoJke1Yx+iU4rL7/7kUjES4dZ/81Qjhyxg==
-
"@img/sharp-libvips-linux-x64@1.2.4":
version "1.2.4"
resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz#df4183e8bd8410f7d61b66859a35edeab0a531ce"
integrity sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==
-"@img/sharp-libvips-linuxmusl-arm64@1.0.4":
- version "1.0.4"
- resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz#166778da0f48dd2bded1fa3033cee6b588f0d5d5"
- integrity sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==
-
-"@img/sharp-libvips-linuxmusl-arm64@1.2.3":
- version "1.2.3"
- resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.3.tgz#ac99576630dd8e33cb598d7c4586f6e0655912ea"
- integrity sha512-F9q83RZ8yaCwENw1GieztSfj5msz7GGykG/BA+MOUefvER69K/ubgFHNeSyUu64amHIYKGDs4sRCMzXVj8sEyw==
-
"@img/sharp-libvips-linuxmusl-arm64@1.2.4":
version "1.2.4"
resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz#c8d6b48211df67137541007ee8d1b7b1f8ca8e06"
integrity sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==
-"@img/sharp-libvips-linuxmusl-x64@1.0.4":
- version "1.0.4"
- resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz#93794e4d7720b077fcad3e02982f2f1c246751ff"
- integrity sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==
-
-"@img/sharp-libvips-linuxmusl-x64@1.2.3":
- version "1.2.3"
- resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.3.tgz#93e9495af7bf6c4e0d41dd71d0196c35c3753a1c"
- integrity sha512-U5PUY5jbc45ANM6tSJpsgqmBF/VsL6LnxJmIf11kB7J5DctHgqm0SkuXzVWtIY90GnJxKnC/JT251TDnk1fu/g==
-
"@img/sharp-libvips-linuxmusl-x64@1.2.4":
version "1.2.4"
resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz#be11c75bee5b080cbee31a153a8779448f919f75"
integrity sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==
-"@img/sharp-linux-arm64@0.33.5":
- version "0.33.5"
- resolved "https://registry.yarnpkg.com/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz#edb0697e7a8279c9fc829a60fc35644c4839bb22"
- integrity sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==
- optionalDependencies:
- "@img/sharp-libvips-linux-arm64" "1.0.4"
-
-"@img/sharp-linux-arm64@0.34.4":
- version "0.34.4"
- resolved "https://registry.yarnpkg.com/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.4.tgz#0570ff1a4fa6e1d6779456fca8b5e8c18a6a9cf2"
- integrity sha512-YXU1F/mN/Wu786tl72CyJjP/Ngl8mGHN1hST4BGl+hiW5jhCnV2uRVTNOcaYPs73NeT/H8Upm3y9582JVuZHrQ==
- optionalDependencies:
- "@img/sharp-libvips-linux-arm64" "1.2.3"
-
"@img/sharp-linux-arm64@0.34.5":
version "0.34.5"
resolved "https://registry.yarnpkg.com/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz#7aa7764ef9c001f15e610546d42fce56911790cc"
@@ -3219,20 +3092,6 @@
optionalDependencies:
"@img/sharp-libvips-linux-arm64" "1.2.4"
-"@img/sharp-linux-arm@0.33.5":
- version "0.33.5"
- resolved "https://registry.yarnpkg.com/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz#422c1a352e7b5832842577dc51602bcd5b6f5eff"
- integrity sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==
- optionalDependencies:
- "@img/sharp-libvips-linux-arm" "1.0.5"
-
-"@img/sharp-linux-arm@0.34.4":
- version "0.34.4"
- resolved "https://registry.yarnpkg.com/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.4.tgz#5f020d933f54f3fc49203d32c3b7dd0ec11ffcdb"
- integrity sha512-Xyam4mlqM0KkTHYVSuc6wXRmM7LGN0P12li03jAnZ3EJWZqj83+hi8Y9UxZUbxsgsK1qOEwg7O0Bc0LjqQVtxA==
- optionalDependencies:
- "@img/sharp-libvips-linux-arm" "1.2.3"
-
"@img/sharp-linux-arm@0.34.5":
version "0.34.5"
resolved "https://registry.yarnpkg.com/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz#5fb0c3695dd12522d39c3ff7a6bc816461780a0d"
@@ -3240,13 +3099,6 @@
optionalDependencies:
"@img/sharp-libvips-linux-arm" "1.2.4"
-"@img/sharp-linux-ppc64@0.34.4":
- version "0.34.4"
- resolved "https://registry.yarnpkg.com/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.4.tgz#8d5775f6dc7e30ea3a1efa43798b7690bb5cb344"
- integrity sha512-F4PDtF4Cy8L8hXA2p3TO6s4aDt93v+LKmpcYFLAVdkkD3hSxZzee0rh6/+94FpAynsuMpLX5h+LRsSG3rIciUQ==
- optionalDependencies:
- "@img/sharp-libvips-linux-ppc64" "1.2.3"
-
"@img/sharp-linux-ppc64@0.34.5":
version "0.34.5"
resolved "https://registry.yarnpkg.com/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz#9c213a81520a20caf66978f3d4c07456ff2e0813"
@@ -3261,20 +3113,6 @@
optionalDependencies:
"@img/sharp-libvips-linux-riscv64" "1.2.4"
-"@img/sharp-linux-s390x@0.33.5":
- version "0.33.5"
- resolved "https://registry.yarnpkg.com/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz#f5c077926b48e97e4a04d004dfaf175972059667"
- integrity sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==
- optionalDependencies:
- "@img/sharp-libvips-linux-s390x" "1.0.4"
-
-"@img/sharp-linux-s390x@0.34.4":
- version "0.34.4"
- resolved "https://registry.yarnpkg.com/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.4.tgz#740aa5b369188ee2c1913b1015e7f830f4dfdb50"
- integrity sha512-qVrZKE9Bsnzy+myf7lFKvng6bQzhNUAYcVORq2P7bDlvmF6u2sCmK2KyEQEBdYk+u3T01pVsPrkj943T1aJAsw==
- optionalDependencies:
- "@img/sharp-libvips-linux-s390x" "1.2.3"
-
"@img/sharp-linux-s390x@0.34.5":
version "0.34.5"
resolved "https://registry.yarnpkg.com/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz#93eac601b9f329bb27917e0e19098c722d630df7"
@@ -3282,20 +3120,6 @@
optionalDependencies:
"@img/sharp-libvips-linux-s390x" "1.2.4"
-"@img/sharp-linux-x64@0.33.5":
- version "0.33.5"
- resolved "https://registry.yarnpkg.com/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz#d806e0afd71ae6775cc87f0da8f2d03a7c2209cb"
- integrity sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==
- optionalDependencies:
- "@img/sharp-libvips-linux-x64" "1.0.4"
-
-"@img/sharp-linux-x64@0.34.4":
- version "0.34.4"
- resolved "https://registry.yarnpkg.com/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.4.tgz#573ce4196b2d0771bba32acc13a37b7adc9b6212"
- integrity sha512-ZfGtcp2xS51iG79c6Vhw9CWqQC8l2Ot8dygxoDoIQPTat/Ov3qAa8qpxSrtAEAJW+UjTXc4yxCjNfxm4h6Xm2A==
- optionalDependencies:
- "@img/sharp-libvips-linux-x64" "1.2.3"
-
"@img/sharp-linux-x64@0.34.5":
version "0.34.5"
resolved "https://registry.yarnpkg.com/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz#55abc7cd754ffca5002b6c2b719abdfc846819a8"
@@ -3303,20 +3127,6 @@
optionalDependencies:
"@img/sharp-libvips-linux-x64" "1.2.4"
-"@img/sharp-linuxmusl-arm64@0.33.5":
- version "0.33.5"
- resolved "https://registry.yarnpkg.com/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz#252975b915894fb315af5deea174651e208d3d6b"
- integrity sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==
- optionalDependencies:
- "@img/sharp-libvips-linuxmusl-arm64" "1.0.4"
-
-"@img/sharp-linuxmusl-arm64@0.34.4":
- version "0.34.4"
- resolved "https://registry.yarnpkg.com/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.4.tgz#3c91bc8348cc3b42b43c6fca14f9dbb5cb47bd0d"
- integrity sha512-8hDVvW9eu4yHWnjaOOR8kHVrew1iIX+MUgwxSuH2XyYeNRtLUe4VNioSqbNkB7ZYQJj9rUTT4PyRscyk2PXFKA==
- optionalDependencies:
- "@img/sharp-libvips-linuxmusl-arm64" "1.2.3"
-
"@img/sharp-linuxmusl-arm64@0.34.5":
version "0.34.5"
resolved "https://registry.yarnpkg.com/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz#d6515ee971bb62f73001a4829b9d865a11b77086"
@@ -3324,20 +3134,6 @@
optionalDependencies:
"@img/sharp-libvips-linuxmusl-arm64" "1.2.4"
-"@img/sharp-linuxmusl-x64@0.33.5":
- version "0.33.5"
- resolved "https://registry.yarnpkg.com/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz#3f4609ac5d8ef8ec7dadee80b560961a60fd4f48"
- integrity sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==
- optionalDependencies:
- "@img/sharp-libvips-linuxmusl-x64" "1.0.4"
-
-"@img/sharp-linuxmusl-x64@0.34.4":
- version "0.34.4"
- resolved "https://registry.yarnpkg.com/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.4.tgz#33de7d476ac9e2db7ef654331b54cc679b806bda"
- integrity sha512-lU0aA5L8QTlfKjpDCEFOZsTYGn3AEiO6db8W5aQDxj0nQkVrZWmN3ZP9sYKWJdtq3PWPhUNlqehWyXpYDcI9Sg==
- optionalDependencies:
- "@img/sharp-libvips-linuxmusl-x64" "1.2.3"
-
"@img/sharp-linuxmusl-x64@0.34.5":
version "0.34.5"
resolved "https://registry.yarnpkg.com/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz#d97978aec7c5212f999714f2f5b736457e12ee9f"
@@ -3345,20 +3141,6 @@
optionalDependencies:
"@img/sharp-libvips-linuxmusl-x64" "1.2.4"
-"@img/sharp-wasm32@0.33.5":
- version "0.33.5"
- resolved "https://registry.yarnpkg.com/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz#6f44f3283069d935bb5ca5813153572f3e6f61a1"
- integrity sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==
- dependencies:
- "@emnapi/runtime" "^1.2.0"
-
-"@img/sharp-wasm32@0.34.4":
- version "0.34.4"
- resolved "https://registry.yarnpkg.com/@img/sharp-wasm32/-/sharp-wasm32-0.34.4.tgz#d617f7b3f851f899802298f360667c20605c0198"
- integrity sha512-33QL6ZO/qpRyG7woB/HUALz28WnTMI2W1jgX3Nu2bypqLIKx/QKMILLJzJjI+SIbvXdG9fUnmrxR7vbi1sTBeA==
- dependencies:
- "@emnapi/runtime" "^1.5.0"
-
"@img/sharp-wasm32@0.34.5":
version "0.34.5"
resolved "https://registry.yarnpkg.com/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz#2f15803aa626f8c59dd7c9d0bbc766f1ab52cfa0"
@@ -3366,41 +3148,16 @@
dependencies:
"@emnapi/runtime" "^1.7.0"
-"@img/sharp-win32-arm64@0.34.4":
- version "0.34.4"
- resolved "https://registry.yarnpkg.com/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.4.tgz#38e2c8a88826eac647f7c3f99efefb39897a8f5c"
- integrity sha512-2Q250do/5WXTwxW3zjsEuMSv5sUU4Tq9VThWKlU2EYLm4MB7ZeMwF+SFJutldYODXF6jzc6YEOC+VfX0SZQPqA==
-
"@img/sharp-win32-arm64@0.34.5":
version "0.34.5"
resolved "https://registry.yarnpkg.com/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz#3706e9e3ac35fddfc1c87f94e849f1b75307ce0a"
integrity sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==
-"@img/sharp-win32-ia32@0.33.5":
- version "0.33.5"
- resolved "https://registry.yarnpkg.com/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz#1a0c839a40c5351e9885628c85f2e5dfd02b52a9"
- integrity sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==
-
-"@img/sharp-win32-ia32@0.34.4":
- version "0.34.4"
- resolved "https://registry.yarnpkg.com/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.4.tgz#003a7eb0fdaba600790c3007cfd756e41a9cf749"
- integrity sha512-3ZeLue5V82dT92CNL6rsal6I2weKw1cYu+rGKm8fOCCtJTR2gYeUfY3FqUnIJsMUPIH68oS5jmZ0NiJ508YpEw==
-
"@img/sharp-win32-ia32@0.34.5":
version "0.34.5"
resolved "https://registry.yarnpkg.com/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz#0b71166599b049e032f085fb9263e02f4e4788de"
integrity sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==
-"@img/sharp-win32-x64@0.33.5":
- version "0.33.5"
- resolved "https://registry.yarnpkg.com/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz#56f00962ff0c4e0eb93d34a047d29fa995e3e342"
- integrity sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==
-
-"@img/sharp-win32-x64@0.34.4":
- version "0.34.4"
- resolved "https://registry.yarnpkg.com/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.4.tgz#b19f1f88ace8bfc20784a0ad31767f3438e025d1"
- integrity sha512-xIyj4wpYs8J18sVN3mSQjwrw7fKUqRw+Z5rnHNCy5fYTxigBz81u5mOMPmFumwjcn8+ld1ppptMBCLic1nz6ig==
-
"@img/sharp-win32-x64@0.34.5":
version "0.34.5"
resolved "https://registry.yarnpkg.com/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz#a81ffb00e69267cd0a1d626eaedb8a8430b2b2f8"
@@ -10077,27 +9834,11 @@ color-name@1.1.3:
resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25"
integrity sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==
-color-name@^1.0.0, color-name@^1.1.4, color-name@~1.1.4:
+color-name@^1.1.4, color-name@~1.1.4:
version "1.1.4"
resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2"
integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==
-color-string@^1.9.0:
- version "1.9.1"
- resolved "https://registry.yarnpkg.com/color-string/-/color-string-1.9.1.tgz#4467f9146f036f855b764dfb5bf8582bf342c7a4"
- integrity sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==
- dependencies:
- color-name "^1.0.0"
- simple-swizzle "^0.2.2"
-
-color@^4.2.3:
- version "4.2.3"
- resolved "https://registry.yarnpkg.com/color/-/color-4.2.3.tgz#d781ecb5e57224ee43ea9627560107c0e0c6463a"
- integrity sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==
- dependencies:
- color-convert "^2.0.1"
- color-string "^1.9.0"
-
colord@^2.9.3:
version "2.9.3"
resolved "https://registry.yarnpkg.com/colord/-/colord-2.9.3.tgz#4f8ce919de456f1d5c1c368c307fe20f3e59fb43"
@@ -10874,7 +10615,7 @@ detect-libc@^1.0.3:
resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-1.0.3.tgz#fa137c4bd698edf55cd5cd02ac559f91a4c4ba9b"
integrity sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==
-detect-libc@^2.0.0, detect-libc@^2.0.3, detect-libc@^2.0.4, detect-libc@^2.1.0:
+detect-libc@^2.0.0, detect-libc@^2.0.3, detect-libc@^2.0.4:
version "2.1.1"
resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-2.1.1.tgz#9f1e511ace6bb525efea4651345beac424dac7b9"
integrity sha512-ecqj/sy1jcK1uWrwpR67UhYrIFQ+5WlGxth34WquCbamhFA6hkkwiu37o6J5xCHdo1oixJRfVRw+ywV+Hq/0Aw==
@@ -13917,11 +13658,6 @@ is-arrayish@^0.2.1:
resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.2.1.tgz#77c99840527aa8ecb1a8ba697b80645a7a926a9d"
integrity sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==
-is-arrayish@^0.3.1:
- version "0.3.4"
- resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.3.4.tgz#1ee5553818511915685d33bb13d31bf854e5059d"
- integrity sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==
-
is-async-function@^2.0.0:
version "2.1.1"
resolved "https://registry.yarnpkg.com/is-async-function/-/is-async-function-2.1.1.tgz#3e69018c8e04e73b738793d020bfe884b9fd3523"
@@ -19992,36 +19728,7 @@ setprototypeof@1.2.0:
resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.2.0.tgz#66c9a24a73f9fc28cbe66b09fed3d33dcaf1b424"
integrity sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==
-sharp@^0.33.5:
- version "0.33.5"
- resolved "https://registry.yarnpkg.com/sharp/-/sharp-0.33.5.tgz#13e0e4130cc309d6a9497596715240b2ec0c594e"
- integrity sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==
- dependencies:
- color "^4.2.3"
- detect-libc "^2.0.3"
- semver "^7.6.3"
- optionalDependencies:
- "@img/sharp-darwin-arm64" "0.33.5"
- "@img/sharp-darwin-x64" "0.33.5"
- "@img/sharp-libvips-darwin-arm64" "1.0.4"
- "@img/sharp-libvips-darwin-x64" "1.0.4"
- "@img/sharp-libvips-linux-arm" "1.0.5"
- "@img/sharp-libvips-linux-arm64" "1.0.4"
- "@img/sharp-libvips-linux-s390x" "1.0.4"
- "@img/sharp-libvips-linux-x64" "1.0.4"
- "@img/sharp-libvips-linuxmusl-arm64" "1.0.4"
- "@img/sharp-libvips-linuxmusl-x64" "1.0.4"
- "@img/sharp-linux-arm" "0.33.5"
- "@img/sharp-linux-arm64" "0.33.5"
- "@img/sharp-linux-s390x" "0.33.5"
- "@img/sharp-linux-x64" "0.33.5"
- "@img/sharp-linuxmusl-arm64" "0.33.5"
- "@img/sharp-linuxmusl-x64" "0.33.5"
- "@img/sharp-wasm32" "0.33.5"
- "@img/sharp-win32-ia32" "0.33.5"
- "@img/sharp-win32-x64" "0.33.5"
-
-sharp@^0.34.3, sharp@^0.34.5:
+sharp@0.34.5, sharp@^0.33.5, sharp@^0.34.3, sharp@^0.34.4, sharp@^0.34.5:
version "0.34.5"
resolved "https://registry.yarnpkg.com/sharp/-/sharp-0.34.5.tgz#b6f148e4b8c61f1797bde11a9d1cfebbae2c57b0"
integrity sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==
@@ -20055,38 +19762,6 @@ sharp@^0.34.3, sharp@^0.34.5:
"@img/sharp-win32-ia32" "0.34.5"
"@img/sharp-win32-x64" "0.34.5"
-sharp@^0.34.4:
- version "0.34.4"
- resolved "https://registry.yarnpkg.com/sharp/-/sharp-0.34.4.tgz#73c2c5a425e98250b8b927e5537f711da8966e38"
- integrity sha512-FUH39xp3SBPnxWvd5iib1X8XY7J0K0X7d93sie9CJg2PO8/7gmg89Nve6OjItK53/MlAushNNxteBYfM6DEuoA==
- dependencies:
- "@img/colour" "^1.0.0"
- detect-libc "^2.1.0"
- semver "^7.7.2"
- optionalDependencies:
- "@img/sharp-darwin-arm64" "0.34.4"
- "@img/sharp-darwin-x64" "0.34.4"
- "@img/sharp-libvips-darwin-arm64" "1.2.3"
- "@img/sharp-libvips-darwin-x64" "1.2.3"
- "@img/sharp-libvips-linux-arm" "1.2.3"
- "@img/sharp-libvips-linux-arm64" "1.2.3"
- "@img/sharp-libvips-linux-ppc64" "1.2.3"
- "@img/sharp-libvips-linux-s390x" "1.2.3"
- "@img/sharp-libvips-linux-x64" "1.2.3"
- "@img/sharp-libvips-linuxmusl-arm64" "1.2.3"
- "@img/sharp-libvips-linuxmusl-x64" "1.2.3"
- "@img/sharp-linux-arm" "0.34.4"
- "@img/sharp-linux-arm64" "0.34.4"
- "@img/sharp-linux-ppc64" "0.34.4"
- "@img/sharp-linux-s390x" "0.34.4"
- "@img/sharp-linux-x64" "0.34.4"
- "@img/sharp-linuxmusl-arm64" "0.34.4"
- "@img/sharp-linuxmusl-x64" "0.34.4"
- "@img/sharp-wasm32" "0.34.4"
- "@img/sharp-win32-arm64" "0.34.4"
- "@img/sharp-win32-ia32" "0.34.4"
- "@img/sharp-win32-x64" "0.34.4"
-
shebang-command@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-2.0.0.tgz#ccd0af4f8835fbdc265b82461aaf0c36663f34ea"
@@ -20210,13 +19885,6 @@ simple-git@^3.28.0:
"@kwsites/promise-deferred" "^1.1.1"
debug "^4.4.0"
-simple-swizzle@^0.2.2:
- version "0.2.4"
- resolved "https://registry.yarnpkg.com/simple-swizzle/-/simple-swizzle-0.2.4.tgz#a8d11a45a11600d6a1ecdff6363329e3648c3667"
- integrity sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==
- dependencies:
- is-arrayish "^0.3.1"
-
sirv@^3.0.1, sirv@^3.0.2:
version "3.0.2"
resolved "https://registry.yarnpkg.com/sirv/-/sirv-3.0.2.tgz#f775fccf10e22a40832684848d636346f41cd970"
From c9ae5dac7ccce4d2a535068e9a036e08fc5bfe09 Mon Sep 17 00:00:00 2001
From: Omar Ibrahim
Date: Tue, 16 Jun 2026 21:26:41 -0500
Subject: [PATCH 026/101] Minor(modeling-commons): use phrasing 'authored in'
over 'written in'
---
apps/modeling-commons-frontend/.env.example | 2 +-
.../app/components/model/detail/ModelHeader.vue | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/apps/modeling-commons-frontend/.env.example b/apps/modeling-commons-frontend/.env.example
index 298fcc8a..8c49f5e4 100644
--- a/apps/modeling-commons-frontend/.env.example
+++ b/apps/modeling-commons-frontend/.env.example
@@ -1,7 +1,7 @@
NUXT_TELEMETRY_DISABLED=true
PRODUCT_NAME="Modeling Commons"
-PRODUCT_DESCRIPTION="The Modeling Commons is for sharing and discussing agent-based models written in NetLogo. "
+PRODUCT_DESCRIPTION="The Modeling Commons is for sharing and discussing agent-based models authored in NetLogo. "
PRODUCT_KEYWORDS="NetLogo, modeling, simulation, agent-based modeling, system dynamics"
PRODUCT_VERSION=
PRODUCT_DISPLAY_NAME=
diff --git a/apps/modeling-commons-frontend/app/components/model/detail/ModelHeader.vue b/apps/modeling-commons-frontend/app/components/model/detail/ModelHeader.vue
index 20f068fb..2f7412ca 100644
--- a/apps/modeling-commons-frontend/app/components/model/detail/ModelHeader.vue
+++ b/apps/modeling-commons-frontend/app/components/model/detail/ModelHeader.vue
@@ -44,7 +44,7 @@
{{ relativeDate }}
- Written in {{ netlogoVersion }}
+ Authored in {{ netlogoVersion }}
From ef0e46596e37805e9cdac23d3afdc15ca61c3fd8 Mon Sep 17 00:00:00 2001
From: Omar Ibrahim
Date: Tue, 16 Jun 2026 21:28:38 -0500
Subject: [PATCH 027/101] Minor(modeling-commons): use consistent 'Log In' copy
---
.../app/components/layout/ClientNavbar.vue | 2 +-
apps/modeling-commons-frontend/app/layouts/profile.vue | 2 +-
apps/modeling-commons-frontend/app/pages/(auth)/login.vue | 8 ++++----
.../modeling-commons-frontend/app/pages/(auth)/signup.vue | 2 +-
.../tests/nuxt/pages/seo-meta.test.ts | 2 +-
5 files changed, 8 insertions(+), 8 deletions(-)
diff --git a/apps/modeling-commons-frontend/app/components/layout/ClientNavbar.vue b/apps/modeling-commons-frontend/app/components/layout/ClientNavbar.vue
index df8e6e40..1b2d819f 100644
--- a/apps/modeling-commons-frontend/app/components/layout/ClientNavbar.vue
+++ b/apps/modeling-commons-frontend/app/components/layout/ClientNavbar.vue
@@ -168,7 +168,7 @@ const userDropdownItems = computed>>(() => {
href: "/profile/settings",
},
{
- label: "Login & Security",
+ label: "Log In & Security",
icon: "i-lucide-key-round",
href: "/profile/security",
},
diff --git a/apps/modeling-commons-frontend/app/layouts/profile.vue b/apps/modeling-commons-frontend/app/layouts/profile.vue
index 3c63a914..d980c6ed 100644
--- a/apps/modeling-commons-frontend/app/layouts/profile.vue
+++ b/apps/modeling-commons-frontend/app/layouts/profile.vue
@@ -74,7 +74,7 @@ const navItems = computed(() => [
[
{ label: "Account", type: "label" },
{ label: "Public Profile", icon: "i-lucide-user-cog", to: "/profile/settings" },
- { label: "Login & Security", icon: "i-lucide-key-round", to: "/profile/security" },
+ { label: "Log In & Security", icon: "i-lucide-key-round", to: "/profile/security" },
// {
// label: "Notifications & Preferences",
// icon: "i-lucide-sliders-horizontal",
diff --git a/apps/modeling-commons-frontend/app/pages/(auth)/login.vue b/apps/modeling-commons-frontend/app/pages/(auth)/login.vue
index 47ebae71..a87024b2 100644
--- a/apps/modeling-commons-frontend/app/pages/(auth)/login.vue
+++ b/apps/modeling-commons-frontend/app/pages/(auth)/login.vue
@@ -80,8 +80,8 @@ definePageMeta({
});
useSeoMeta({
- title: "Login",
- description: "Login to your account to continue",
+ title: "Log In",
+ description: "Log In to your account to continue",
});
const meta = useWebsite();
@@ -187,11 +187,11 @@ async function onSubmit(payload?: FormSubmitEvent): Promise {
}
if (error) {
- showActionFailedToast("Login", error.message);
+ showActionFailedToast("Log In", error.message);
return;
}
} catch (error) {
- showActionFailedToast("Login", (error as Error).message);
+ showActionFailedToast("Log In", (error as Error).message);
return;
}
diff --git a/apps/modeling-commons-frontend/app/pages/(auth)/signup.vue b/apps/modeling-commons-frontend/app/pages/(auth)/signup.vue
index dbe8f18c..5b050a5f 100644
--- a/apps/modeling-commons-frontend/app/pages/(auth)/signup.vue
+++ b/apps/modeling-commons-frontend/app/pages/(auth)/signup.vue
@@ -84,7 +84,7 @@ async function onSubmit(payload: FormSubmitEvent) {
Already have an account?
- Login.
+ Log In.
diff --git a/apps/modeling-commons-frontend/tests/nuxt/pages/seo-meta.test.ts b/apps/modeling-commons-frontend/tests/nuxt/pages/seo-meta.test.ts
index 1316f2ed..ad23ad17 100644
--- a/apps/modeling-commons-frontend/tests/nuxt/pages/seo-meta.test.ts
+++ b/apps/modeling-commons-frontend/tests/nuxt/pages/seo-meta.test.ts
@@ -168,7 +168,7 @@ describe("Page SEO meta", () => {
await mountSuspended(Page);
const meta = lastSeoCall();
expect(meta).not.toBeNull();
- expect(String(resolveMaybe(meta!.title))).toContain("Login");
+ expect(String(resolveMaybe(meta!.title))).toContain("Log In");
expect(String(resolveMaybe(meta!.description) ?? "").length).toBeGreaterThan(0);
});
From 9f446e4efaad88b51dee75e4e72f8e14e6529947 Mon Sep 17 00:00:00 2001
From: Omar Ibrahim
Date: Tue, 16 Jun 2026 21:32:33 -0500
Subject: [PATCH 028/101] Minor(modeling-commons): use warmer contact us copy
for errors
---
apps/modeling-commons-frontend/app/utils/errors.ts | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/apps/modeling-commons-frontend/app/utils/errors.ts b/apps/modeling-commons-frontend/app/utils/errors.ts
index e1ba58f3..2d20ad2a 100644
--- a/apps/modeling-commons-frontend/app/utils/errors.ts
+++ b/apps/modeling-commons-frontend/app/utils/errors.ts
@@ -20,7 +20,9 @@ export function handleApiError(
data: NonNullable | undefined | null,
error: ApiError | undefined,
when: string | undefined = undefined,
- whatNow: string | undefined = "try again later or contact support",
+ whatNow:
+ | string
+ | undefined = "try again later or reach out via email, GitHub, or the NetLogo Forum if the problem persists",
): T {
if (error) {
throw createApiError(error, when, whatNow);
From ae4333f397642511fbbafd74d4e481f60766c821 Mon Sep 17 00:00:00 2001
From: Omar Ibrahim
Date: Tue, 16 Jun 2026 21:35:52 -0500
Subject: [PATCH 029/101] Minor(modeling-commons): better copy for donate
headline
---
apps/modeling-commons-frontend/app/pages/donate.vue | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/apps/modeling-commons-frontend/app/pages/donate.vue b/apps/modeling-commons-frontend/app/pages/donate.vue
index ccb03ffa..35b22e0c 100644
--- a/apps/modeling-commons-frontend/app/pages/donate.vue
+++ b/apps/modeling-commons-frontend/app/pages/donate.vue
@@ -17,7 +17,7 @@
From 74ceac46ea2e510aa8a916d10524fb1d7b1be131 Mon Sep 17 00:00:00 2001
From: Omar Ibrahim
Date: Tue, 16 Jun 2026 22:16:28 -0500
Subject: [PATCH 031/101] Minor(modeling-commons): Use more informative error
message for failed preview image generation
---
.../domain/preview-image.errors.spec.ts | 24 +------------------
.../domain/preview-image.errors.ts | 12 +++++-----
.../preview-image/preview-image.service.ts | 11 ++++-----
3 files changed, 12 insertions(+), 35 deletions(-)
diff --git a/apps/modeling-commons-backend/src/modules/preview-image/domain/preview-image.errors.spec.ts b/apps/modeling-commons-backend/src/modules/preview-image/domain/preview-image.errors.spec.ts
index 5275ef1d..8dcf49e9 100644
--- a/apps/modeling-commons-backend/src/modules/preview-image/domain/preview-image.errors.spec.ts
+++ b/apps/modeling-commons-backend/src/modules/preview-image/domain/preview-image.errors.spec.ts
@@ -1,9 +1,5 @@
import { describe, it, expect } from 'vitest';
-import {
- ModelPreviewServiceError,
- ModelPreviewTimeoutError,
- ModelPreviewTooLargeError,
-} from '#src/modules/preview-image/domain/preview-image.errors.ts';
+import { ModelPreviewServiceError } from '#src/modules/preview-image/domain/preview-image.errors.ts';
describe('preview-image errors', () => {
it('ModelPreviewServiceError carries the netlogo key and an HTTP 500 status', () => {
@@ -11,22 +7,4 @@ describe('preview-image errors', () => {
expect(err.message).toContain('key.nlogo');
expect(err.statusCode).toBe(500);
});
-
- it('ModelPreviewServiceError preserves the original cause', () => {
- const cause = new Error('upstream failure');
- const err = new ModelPreviewServiceError('key.nlogo', cause);
- expect(err.cause).toBe(cause);
- });
-
- it('ModelPreviewTimeoutError is a ModelPreviewServiceError describing the timeout', () => {
- const err = new ModelPreviewTimeoutError('key.nlogo');
- expect(err).toBeInstanceOf(ModelPreviewServiceError);
- expect((err.cause as Error).message).toMatch(/timed out/i);
- });
-
- it('ModelPreviewTooLargeError is a ModelPreviewServiceError describing the size limit', () => {
- const err = new ModelPreviewTooLargeError('key.nlogo');
- expect(err).toBeInstanceOf(ModelPreviewServiceError);
- expect((err.cause as Error).message).toMatch(/size limit/i);
- });
});
diff --git a/apps/modeling-commons-backend/src/modules/preview-image/domain/preview-image.errors.ts b/apps/modeling-commons-backend/src/modules/preview-image/domain/preview-image.errors.ts
index 13cf7f59..b3964c4f 100644
--- a/apps/modeling-commons-backend/src/modules/preview-image/domain/preview-image.errors.ts
+++ b/apps/modeling-commons-backend/src/modules/preview-image/domain/preview-image.errors.ts
@@ -1,19 +1,19 @@
import { ProviderErrorException } from '#src/shared/exceptions/exceptions.ts';
export class ModelPreviewServiceError extends ProviderErrorException {
- constructor(netlogoFileKey: string, cause?: Error) {
- super(`Preview image for NetLogo file ${netlogoFileKey} could not be generated`, cause);
+ constructor(message: string = 'Model preview generation failed') {
+ super(message);
}
}
export class ModelPreviewTimeoutError extends ModelPreviewServiceError {
- constructor(netlogoFileKey: string) {
- super(netlogoFileKey, new Error(`Preview image generation timed out after 30 seconds`));
+ constructor() {
+ super(`Preview image generation timed out after 30 seconds`);
}
}
export class ModelPreviewTooLargeError extends ModelPreviewServiceError {
- constructor(netlogoFileKey: string) {
- super(netlogoFileKey, new Error(`Preview image exceeded size limit of 5MB`));
+ constructor() {
+ super(`Preview image exceeded size limit of 5MB`);
}
}
diff --git a/apps/modeling-commons-backend/src/modules/preview-image/preview-image.service.ts b/apps/modeling-commons-backend/src/modules/preview-image/preview-image.service.ts
index f1b73645..ca535923 100644
--- a/apps/modeling-commons-backend/src/modules/preview-image/preview-image.service.ts
+++ b/apps/modeling-commons-backend/src/modules/preview-image/preview-image.service.ts
@@ -44,28 +44,27 @@ export default function makePreviewImageService({ fileService }: Dependencies) {
} catch (error) {
clearTimeout(timeout);
if ((error as Error).name === 'AbortError') {
- throw new ModelPreviewTimeoutError(netlogoFileKey);
+ throw new ModelPreviewTimeoutError();
}
- throw new ModelPreviewServiceError(netlogoFileKey, error as Error);
+ throw new ModelPreviewServiceError((error as Error).message);
}
clearTimeout(timeout);
if (!image.ok) {
throw new ModelPreviewServiceError(
- netlogoFileKey,
- new Error(`Rendering service responded with status ${image.status}`),
+ `Rendering service responded with status ${image.status}.`,
);
}
const contentLength = Number(image.headers.get('Content-Length'));
if (contentLength && contentLength > MAX_IMAGE_SIZE) {
await image.body?.cancel();
- throw new ModelPreviewTooLargeError(netlogoFileKey);
+ throw new ModelPreviewTooLargeError();
}
const buffer = await image.arrayBuffer();
if (buffer.byteLength > MAX_IMAGE_SIZE) {
- throw new ModelPreviewTooLargeError(netlogoFileKey);
+ throw new ModelPreviewTooLargeError();
}
return {
From 44348765c35d90c6d98ad6065a05a5c14f7f642e Mon Sep 17 00:00:00 2001
From: Omar Ibrahim
Date: Tue, 16 Jun 2026 22:16:45 -0500
Subject: [PATCH 032/101] Chore(modeling-commons): Update cucumber test reports
---
.../reports/cucumber-report.json | 14126 +++++++++++++++-
.../reports/index.html | 2 +-
2 files changed, 14113 insertions(+), 15 deletions(-)
diff --git a/apps/modeling-commons-backend/reports/cucumber-report.json b/apps/modeling-commons-backend/reports/cucumber-report.json
index d16b3a3b..5259b6f8 100644
--- a/apps/modeling-commons-backend/reports/cucumber-report.json
+++ b/apps/modeling-commons-backend/reports/cucumber-report.json
@@ -1,33 +1,33 @@
[
{
- "description": " As a client\n I want to verify the API is running\n So that I can confirm the service is available",
+ "description": " As a user\n I want to sign up and sign in\n So that I can access protected resources",
"elements": [
{
"description": "",
- "id": "health-check;health-endpoint-reports-the-service-is-up",
+ "id": "authentication;sign-up-with-valid-credentials",
"keyword": "Scenario",
"line": 6,
- "name": "Health endpoint reports the service is up",
+ "name": "Sign up with valid credentials",
"steps": [
{
"keyword": "Before",
"hidden": true,
"result": {
"status": "passed",
- "duration": 153971499
+ "duration": 137601583
}
},
{
"arguments": [],
"keyword": "When ",
"line": 7,
- "name": "I send a GET request to \"/api/health\"",
+ "name": "I sign up with name \"Alice\" email \"alice@test.local\" and password \"StrongPass1!\"",
"match": {
- "location": "tests/api/health.steps.ts:4"
+ "location": "tests/api/auth.steps.ts:20"
},
"result": {
"status": "passed",
- "duration": 139452167
+ "duration": 220061332
}
},
{
@@ -40,20 +40,236 @@
},
"result": {
"status": "passed",
- "duration": 85915
+ "duration": 93959
}
},
{
"arguments": [],
"keyword": "And ",
"line": 9,
- "name": "the response body property \"status\" should equal \"ok\"",
+ "name": "the response body should have property \"user\"",
"match": {
- "location": "tests/shared/common.steps.ts:25"
+ "location": "tests/shared/common.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 68375
+ }
+ },
+ {
+ "keyword": "After",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 177208
+ },
+ "embeddings": [
+ {
+ "data": "U3RhdHVzOiBQQVNTRUQuIER1cmF0aW9uOjBz",
+ "mime_type": "text/plain"
+ }
+ ]
+ }
+ ],
+ "tags": [],
+ "type": "scenario"
+ },
+ {
+ "description": "",
+ "id": "authentication;sign-up-with-weak-password",
+ "keyword": "Scenario",
+ "line": 11,
+ "name": "Sign up with weak password",
+ "steps": [
+ {
+ "keyword": "Before",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 87409583
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "When ",
+ "line": 12,
+ "name": "I sign up with name \"Weak\" email \"weak@test.local\" and password \"short\"",
+ "match": {
+ "location": "tests/api/auth.steps.ts:20"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 2893708
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Then ",
+ "line": 13,
+ "name": "the signup should be rejected",
+ "match": {
+ "location": "tests/api/auth.steps.ts:52"
},
+ "result": {
+ "status": "passed",
+ "duration": 37083
+ }
+ },
+ {
+ "keyword": "After",
+ "hidden": true,
"result": {
"status": "passed",
"duration": 58416
+ },
+ "embeddings": [
+ {
+ "data": "U3RhdHVzOiBQQVNTRUQuIER1cmF0aW9uOjBz",
+ "mime_type": "text/plain"
+ }
+ ]
+ }
+ ],
+ "tags": [],
+ "type": "scenario"
+ },
+ {
+ "description": "",
+ "id": "authentication;sign-in-with-valid-credentials",
+ "keyword": "Scenario",
+ "line": 15,
+ "name": "Sign in with valid credentials",
+ "steps": [
+ {
+ "keyword": "Before",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 76190707
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Given ",
+ "line": 16,
+ "name": "a verified user with email \"bob@test.local\" and password \"StrongPass1!\"",
+ "match": {
+ "location": "tests/api/auth.steps.ts:13"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 84222125
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "When ",
+ "line": 17,
+ "name": "I sign in with email \"bob@test.local\" and password \"StrongPass1!\"",
+ "match": {
+ "location": "tests/api/auth.steps.ts:32"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 79377624
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Then ",
+ "line": 18,
+ "name": "the response status should be 200",
+ "match": {
+ "location": "tests/shared/common.steps.ts:13"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 49499
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 19,
+ "name": "the response should include a session cookie",
+ "match": {
+ "location": "tests/api/auth.steps.ts:44"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 29959
+ }
+ },
+ {
+ "keyword": "After",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 57166
+ },
+ "embeddings": [
+ {
+ "data": "U3RhdHVzOiBQQVNTRUQuIER1cmF0aW9uOjBz",
+ "mime_type": "text/plain"
+ }
+ ]
+ }
+ ],
+ "tags": [],
+ "type": "scenario"
+ },
+ {
+ "description": "",
+ "id": "authentication;sign-in-with-wrong-password",
+ "keyword": "Scenario",
+ "line": 21,
+ "name": "Sign in with wrong password",
+ "steps": [
+ {
+ "keyword": "Before",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 77335084
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Given ",
+ "line": 22,
+ "name": "a verified user with email \"carol@test.local\" and password \"StrongPass1!\"",
+ "match": {
+ "location": "tests/api/auth.steps.ts:13"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 73220583
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "When ",
+ "line": 23,
+ "name": "I sign in with email \"carol@test.local\" and password \"WrongPass1!\"",
+ "match": {
+ "location": "tests/api/auth.steps.ts:32"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 69264749
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Then ",
+ "line": 24,
+ "name": "the signin should be rejected",
+ "match": {
+ "location": "tests/api/auth.steps.ts:60"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 77042
}
},
{
@@ -61,7 +277,7 @@
"hidden": true,
"result": {
"status": "passed",
- "duration": 170417
+ "duration": 65291
},
"embeddings": [
{
@@ -75,11 +291,13893 @@
"type": "scenario"
}
],
- "id": "health-check",
+ "id": "authentication",
"line": 1,
"keyword": "Feature",
- "name": "Health Check",
+ "name": "Authentication",
"tags": [],
- "uri": "tests/api/health.feature"
+ "uri": "tests/api/auth.feature"
+ },
+ {
+ "description": " As an admin\n I want to view system events\n So that I can audit activity on the platform",
+ "elements": [
+ {
+ "description": "",
+ "id": "admin-events;admin-can-list-events",
+ "keyword": "Scenario",
+ "line": 6,
+ "name": "Admin can list events",
+ "steps": [
+ {
+ "keyword": "Before",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 85461709
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Given ",
+ "line": 7,
+ "name": "an authenticated admin user \"admin\"",
+ "match": {
+ "location": "tests/api/event.steps.ts:10"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 150294957
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 8,
+ "name": "a public model \"Audited Model\" created by \"admin\"",
+ "match": {
+ "location": "tests/api/model.steps.ts:127"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 98064499
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "When ",
+ "line": 9,
+ "name": "\"admin\" lists admin events",
+ "match": {
+ "location": "tests/api/event.steps.ts:21"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 15297875
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Then ",
+ "line": 10,
+ "name": "the response status should be 200",
+ "match": {
+ "location": "tests/shared/common.steps.ts:13"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 45625
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 11,
+ "name": "the response body should have property \"data\" as an array",
+ "match": {
+ "location": "tests/shared/common.steps.ts:41"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 45000
+ }
+ },
+ {
+ "keyword": "After",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 49332
+ },
+ "embeddings": [
+ {
+ "data": "U3RhdHVzOiBQQVNTRUQuIER1cmF0aW9uOjBz",
+ "mime_type": "text/plain"
+ }
+ ]
+ }
+ ],
+ "tags": [],
+ "type": "scenario"
+ },
+ {
+ "description": "",
+ "id": "admin-events;admin-can-filter-events-by-type",
+ "keyword": "Scenario",
+ "line": 13,
+ "name": "Admin can filter events by type",
+ "steps": [
+ {
+ "keyword": "Before",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 89229291
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Given ",
+ "line": 14,
+ "name": "an authenticated admin user \"admin\"",
+ "match": {
+ "location": "tests/api/event.steps.ts:10"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 149489290
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 15,
+ "name": "a public model \"Event Filter Model\" created by \"admin\"",
+ "match": {
+ "location": "tests/api/model.steps.ts:127"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 49216332
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "When ",
+ "line": 16,
+ "name": "\"admin\" lists admin events with type \"model.created\"",
+ "match": {
+ "location": "tests/api/event.steps.ts:33"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 5351124
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Then ",
+ "line": 17,
+ "name": "the response status should be 200",
+ "match": {
+ "location": "tests/shared/common.steps.ts:13"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 44709
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 18,
+ "name": "the response body should have property \"data\" as an array",
+ "match": {
+ "location": "tests/shared/common.steps.ts:41"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 26875
+ }
+ },
+ {
+ "keyword": "After",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 74000
+ },
+ "embeddings": [
+ {
+ "data": "U3RhdHVzOiBQQVNTRUQuIER1cmF0aW9uOjBz",
+ "mime_type": "text/plain"
+ }
+ ]
+ }
+ ],
+ "tags": [],
+ "type": "scenario"
+ },
+ {
+ "description": "",
+ "id": "admin-events;regular-user-cannot-access-admin-events",
+ "keyword": "Scenario",
+ "line": 20,
+ "name": "Regular user cannot access admin events",
+ "steps": [
+ {
+ "keyword": "Before",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 85353541
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Given ",
+ "line": 21,
+ "name": "an authenticated user \"regular\"",
+ "match": {
+ "location": "tests/api/user.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 146840791
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "When ",
+ "line": 22,
+ "name": "\"regular\" lists admin events",
+ "match": {
+ "location": "tests/api/event.steps.ts:21"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 3070582
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Then ",
+ "line": 23,
+ "name": "the response status should be 403",
+ "match": {
+ "location": "tests/shared/common.steps.ts:13"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 23583
+ }
+ },
+ {
+ "keyword": "After",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 39667
+ },
+ "embeddings": [
+ {
+ "data": "U3RhdHVzOiBQQVNTRUQuIER1cmF0aW9uOjBz",
+ "mime_type": "text/plain"
+ }
+ ]
+ }
+ ],
+ "tags": [],
+ "type": "scenario"
+ },
+ {
+ "description": "",
+ "id": "admin-events;unauthenticated-user-cannot-access-admin-events",
+ "keyword": "Scenario",
+ "line": 25,
+ "name": "Unauthenticated user cannot access admin events",
+ "steps": [
+ {
+ "keyword": "Before",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 88994124
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "When ",
+ "line": 26,
+ "name": "I send a GET request to \"/api/v1/admin/events\"",
+ "match": {
+ "location": "tests/api/health.steps.ts:4"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 915916
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Then ",
+ "line": 27,
+ "name": "the response status should be 401",
+ "match": {
+ "location": "tests/shared/common.steps.ts:13"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 22583
+ }
+ },
+ {
+ "keyword": "After",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 40958
+ },
+ "embeddings": [
+ {
+ "data": "U3RhdHVzOiBQQVNTRUQuIER1cmF0aW9uOjBz",
+ "mime_type": "text/plain"
+ }
+ ]
+ }
+ ],
+ "tags": [],
+ "type": "scenario"
+ },
+ {
+ "description": "",
+ "id": "admin-events;admin-can-filter-events-by-resourcetype",
+ "keyword": "Scenario",
+ "line": 29,
+ "name": "Admin can filter events by resourceType",
+ "steps": [
+ {
+ "keyword": "Before",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 84590832
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Given ",
+ "line": 30,
+ "name": "an authenticated admin user \"admin\"",
+ "match": {
+ "location": "tests/api/event.steps.ts:10"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 146319083
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 31,
+ "name": "a public model \"Resource Filter Model\" created by \"admin\"",
+ "match": {
+ "location": "tests/api/model.steps.ts:127"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 46811083
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "When ",
+ "line": 32,
+ "name": "\"admin\" lists admin events with resourceType \"model\"",
+ "match": {
+ "location": "tests/api/event.steps.ts:45"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 4405500
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Then ",
+ "line": 33,
+ "name": "the response status should be 200",
+ "match": {
+ "location": "tests/shared/common.steps.ts:13"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 27417
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 34,
+ "name": "the response body should have property \"data\" as an array",
+ "match": {
+ "location": "tests/shared/common.steps.ts:41"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 19624
+ }
+ },
+ {
+ "keyword": "After",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 61791
+ },
+ "embeddings": [
+ {
+ "data": "U3RhdHVzOiBQQVNTRUQuIER1cmF0aW9uOjBz",
+ "mime_type": "text/plain"
+ }
+ ]
+ }
+ ],
+ "tags": [],
+ "type": "scenario"
+ }
+ ],
+ "id": "admin-events",
+ "line": 1,
+ "keyword": "Feature",
+ "name": "Admin Events",
+ "tags": [],
+ "uri": "tests/api/event.feature"
+ },
+ {
+ "description": " As an authenticated user\n I want to upload avatar images\n So that my profile is personalised",
+ "elements": [
+ {
+ "description": "",
+ "id": "file-uploads;uploading-an-avatar-requires-authentication",
+ "keyword": "Scenario",
+ "line": 6,
+ "name": "Uploading an avatar requires authentication",
+ "steps": [
+ {
+ "keyword": "Before",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 80938958
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "When ",
+ "line": 7,
+ "name": "an anonymous viewer uploads a PNG avatar",
+ "match": {
+ "location": "tests/api/file.steps.ts:54"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 999624
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Then ",
+ "line": 8,
+ "name": "the response status should be 401",
+ "match": {
+ "location": "tests/shared/common.steps.ts:13"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 32374
+ }
+ },
+ {
+ "keyword": "After",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 49082
+ },
+ "embeddings": [
+ {
+ "data": "U3RhdHVzOiBQQVNTRUQuIER1cmF0aW9uOjBz",
+ "mime_type": "text/plain"
+ }
+ ]
+ }
+ ],
+ "tags": [],
+ "type": "scenario"
+ },
+ {
+ "description": "",
+ "id": "file-uploads;authenticated-user-uploads-a-png-avatar",
+ "keyword": "Scenario",
+ "line": 10,
+ "name": "Authenticated user uploads a PNG avatar",
+ "steps": [
+ {
+ "keyword": "Before",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 78952291
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Given ",
+ "line": 11,
+ "name": "an authenticated user",
+ "match": {
+ "location": "tests/api/user.steps.ts:11"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 155624124
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "When ",
+ "line": 12,
+ "name": "I upload a PNG avatar",
+ "match": {
+ "location": "tests/api/file.steps.ts:64"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 9075541
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Then ",
+ "line": 13,
+ "name": "the response status should be 201",
+ "match": {
+ "location": "tests/shared/common.steps.ts:13"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 48457
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 14,
+ "name": "the response body should have property \"url\"",
+ "match": {
+ "location": "tests/shared/common.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 48333
+ }
+ },
+ {
+ "keyword": "After",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 60334
+ },
+ "embeddings": [
+ {
+ "data": "U3RhdHVzOiBQQVNTRUQuIER1cmF0aW9uOjBz",
+ "mime_type": "text/plain"
+ }
+ ]
+ }
+ ],
+ "tags": [],
+ "type": "scenario"
+ },
+ {
+ "description": "",
+ "id": "file-uploads;upload-rejects-a-disallowed-mime-type",
+ "keyword": "Scenario",
+ "line": 16,
+ "name": "Upload rejects a disallowed mime type",
+ "steps": [
+ {
+ "keyword": "Before",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 87558958
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Given ",
+ "line": 17,
+ "name": "an authenticated user",
+ "match": {
+ "location": "tests/api/user.steps.ts:11"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 160146874
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "When ",
+ "line": 18,
+ "name": "I upload an avatar with mime \"application/pdf\"",
+ "match": {
+ "location": "tests/api/file.steps.ts:75"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 3894415
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Then ",
+ "line": 19,
+ "name": "the response status should be 400",
+ "match": {
+ "location": "tests/shared/common.steps.ts:13"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 36875
+ }
+ },
+ {
+ "keyword": "After",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 45291
+ },
+ "embeddings": [
+ {
+ "data": "U3RhdHVzOiBQQVNTRUQuIER1cmF0aW9uOjBz",
+ "mime_type": "text/plain"
+ }
+ ]
+ }
+ ],
+ "tags": [],
+ "type": "scenario"
+ }
+ ],
+ "id": "file-uploads",
+ "line": 1,
+ "keyword": "Feature",
+ "name": "File Uploads",
+ "tags": [],
+ "uri": "tests/api/file.feature"
+ },
+ {
+ "description": " As a client\n I want to verify the API is running\n So that I can confirm the service is available",
+ "elements": [
+ {
+ "description": "",
+ "id": "health-check;health-endpoint-reports-the-service-is-up",
+ "keyword": "Scenario",
+ "line": 6,
+ "name": "Health endpoint reports the service is up",
+ "steps": [
+ {
+ "keyword": "Before",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 87844749
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "When ",
+ "line": 7,
+ "name": "I send a GET request to \"/api/health\"",
+ "match": {
+ "location": "tests/api/health.steps.ts:4"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 756208
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Then ",
+ "line": 8,
+ "name": "the response status should be 200",
+ "match": {
+ "location": "tests/shared/common.steps.ts:13"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 24792
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 9,
+ "name": "the response body property \"status\" should equal \"ok\"",
+ "match": {
+ "location": "tests/shared/common.steps.ts:25"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 41917
+ }
+ },
+ {
+ "keyword": "After",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 30209
+ },
+ "embeddings": [
+ {
+ "data": "U3RhdHVzOiBQQVNTRUQuIER1cmF0aW9uOjBz",
+ "mime_type": "text/plain"
+ }
+ ]
+ }
+ ],
+ "tags": [],
+ "type": "scenario"
+ }
+ ],
+ "id": "health-check",
+ "line": 1,
+ "keyword": "Feature",
+ "name": "Health Check",
+ "tags": [],
+ "uri": "tests/api/health.feature"
+ },
+ {
+ "description": " As a model author\n I want to attach supplementary files to my model\n So that users can download datasets or assets alongside the model",
+ "elements": [
+ {
+ "description": "",
+ "id": "model-additional-files;listing-additional-files-for-a-model-is-publicly-accessible-on-a-public-model",
+ "keyword": "Scenario",
+ "line": 6,
+ "name": "Listing additional files for a model is publicly accessible on a public model",
+ "steps": [
+ {
+ "keyword": "Before",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 78232500
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Given ",
+ "line": 7,
+ "name": "an authenticated user \"owner\"",
+ "match": {
+ "location": "tests/api/user.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 153326000
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 8,
+ "name": "a public model \"Open Data\" created by \"owner\"",
+ "match": {
+ "location": "tests/api/model.steps.ts:127"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 45982666
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "When ",
+ "line": 9,
+ "name": "I list additional files for \"Open Data\"",
+ "match": {
+ "location": "tests/api/model-additional-file.steps.ts:50"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 8531582
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Then ",
+ "line": 10,
+ "name": "the response status should be 200",
+ "match": {
+ "location": "tests/shared/common.steps.ts:13"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 37624
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 11,
+ "name": "the response body should be an array",
+ "match": {
+ "location": "tests/shared/common.steps.ts:33"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 34999
+ }
+ },
+ {
+ "keyword": "After",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 34832
+ },
+ "embeddings": [
+ {
+ "data": "U3RhdHVzOiBQQVNTRUQuIER1cmF0aW9uOjBz",
+ "mime_type": "text/plain"
+ }
+ ]
+ }
+ ],
+ "tags": [],
+ "type": "scenario"
+ },
+ {
+ "description": "",
+ "id": "model-additional-files;uploading-an-additional-file-requires-authentication",
+ "keyword": "Scenario",
+ "line": 13,
+ "name": "Uploading an additional file requires authentication",
+ "steps": [
+ {
+ "keyword": "Before",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 85843042
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Given ",
+ "line": 14,
+ "name": "an authenticated user \"owner\"",
+ "match": {
+ "location": "tests/api/user.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 146120208
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 15,
+ "name": "a public model \"Locked Down\" created by \"owner\"",
+ "match": {
+ "location": "tests/api/model.steps.ts:127"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 47150625
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "When ",
+ "line": 16,
+ "name": "an anonymous viewer uploads an additional file to \"Locked Down\"",
+ "match": {
+ "location": "tests/api/model-additional-file.steps.ts:89"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 730999
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Then ",
+ "line": 17,
+ "name": "the response status should be 401",
+ "match": {
+ "location": "tests/shared/common.steps.ts:13"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 17374
+ }
+ },
+ {
+ "keyword": "After",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 30374
+ },
+ "embeddings": [
+ {
+ "data": "U3RhdHVzOiBQQVNTRUQuIER1cmF0aW9uOjBz",
+ "mime_type": "text/plain"
+ }
+ ]
+ }
+ ],
+ "tags": [],
+ "type": "scenario"
+ },
+ {
+ "description": "",
+ "id": "model-additional-files;a-non-author-cannot-upload-additional-files",
+ "keyword": "Scenario",
+ "line": 19,
+ "name": "A non-author cannot upload additional files",
+ "steps": [
+ {
+ "keyword": "Before",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 88664291
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Given ",
+ "line": 20,
+ "name": "an authenticated user \"owner\"",
+ "match": {
+ "location": "tests/api/user.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 146895375
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 21,
+ "name": "a public model \"Owned\" created by \"owner\"",
+ "match": {
+ "location": "tests/api/model.steps.ts:127"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 45542166
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 22,
+ "name": "an authenticated user \"stranger\"",
+ "match": {
+ "location": "tests/api/user.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 136207374
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "When ",
+ "line": 23,
+ "name": "\"stranger\" uploads an additional file to \"Owned\"",
+ "match": {
+ "location": "tests/api/model-additional-file.steps.ts:97"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 7782250
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Then ",
+ "line": 24,
+ "name": "the response status should be 403",
+ "match": {
+ "location": "tests/shared/common.steps.ts:13"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 37250
+ }
+ },
+ {
+ "keyword": "After",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 38083
+ },
+ "embeddings": [
+ {
+ "data": "U3RhdHVzOiBQQVNTRUQuIER1cmF0aW9uOjBz",
+ "mime_type": "text/plain"
+ }
+ ]
+ }
+ ],
+ "tags": [],
+ "type": "scenario"
+ },
+ {
+ "description": "",
+ "id": "model-additional-files;owner-uploads-an-additional-file",
+ "keyword": "Scenario",
+ "line": 26,
+ "name": "Owner uploads an additional file",
+ "steps": [
+ {
+ "keyword": "Before",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 92466083
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Given ",
+ "line": 27,
+ "name": "an authenticated user \"owner\"",
+ "match": {
+ "location": "tests/api/user.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 146559333
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 28,
+ "name": "a public model \"With Attachments\" created by \"owner\"",
+ "match": {
+ "location": "tests/api/model.steps.ts:127"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 47202417
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "When ",
+ "line": 29,
+ "name": "\"owner\" uploads an additional file to \"With Attachments\"",
+ "match": {
+ "location": "tests/api/model-additional-file.steps.ts:97"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 21000458
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Then ",
+ "line": 30,
+ "name": "the response status should be 201",
+ "match": {
+ "location": "tests/shared/common.steps.ts:13"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 45665
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 31,
+ "name": "the response body should have property \"id\"",
+ "match": {
+ "location": "tests/shared/common.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 35167
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 32,
+ "name": "the response body should have property \"fileKey\"",
+ "match": {
+ "location": "tests/shared/common.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 26249
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 33,
+ "name": "the response body should have property \"downloadUrl\"",
+ "match": {
+ "location": "tests/shared/common.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 13208
+ }
+ },
+ {
+ "keyword": "After",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 37540
+ },
+ "embeddings": [
+ {
+ "data": "U3RhdHVzOiBQQVNTRUQuIER1cmF0aW9uOjBz",
+ "mime_type": "text/plain"
+ }
+ ]
+ }
+ ],
+ "tags": [],
+ "type": "scenario"
+ },
+ {
+ "description": "",
+ "id": "model-additional-files;uploaded-file-appears-in-the-listing",
+ "keyword": "Scenario",
+ "line": 35,
+ "name": "Uploaded file appears in the listing",
+ "steps": [
+ {
+ "keyword": "Before",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 78449208
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Given ",
+ "line": 36,
+ "name": "an authenticated user \"owner\"",
+ "match": {
+ "location": "tests/api/user.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 145148833
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 37,
+ "name": "a public model \"Listed\" created by \"owner\"",
+ "match": {
+ "location": "tests/api/model.steps.ts:127"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 47123499
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 38,
+ "name": "\"owner\" has uploaded an additional file to \"Listed\"",
+ "match": {
+ "location": "tests/api/model-additional-file.steps.ts:106"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 17949249
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "When ",
+ "line": 39,
+ "name": "I list additional files for \"Listed\"",
+ "match": {
+ "location": "tests/api/model-additional-file.steps.ts:50"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 6825208
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Then ",
+ "line": 40,
+ "name": "the response status should be 200",
+ "match": {
+ "location": "tests/shared/common.steps.ts:13"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 35792
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 41,
+ "name": "the response body should be an array",
+ "match": {
+ "location": "tests/shared/common.steps.ts:33"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 17334
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 42,
+ "name": "the response body should have length 1",
+ "match": {
+ "location": "tests/api/file.steps.ts:89"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 73999
+ }
+ },
+ {
+ "keyword": "After",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 37999
+ },
+ "embeddings": [
+ {
+ "data": "U3RhdHVzOiBQQVNTRUQuIER1cmF0aW9uOjBz",
+ "mime_type": "text/plain"
+ }
+ ]
+ }
+ ],
+ "tags": [],
+ "type": "scenario"
+ },
+ {
+ "description": "",
+ "id": "model-additional-files;filter-listing-by-tagged-version-number",
+ "keyword": "Scenario",
+ "line": 44,
+ "name": "Filter listing by tagged version number",
+ "steps": [
+ {
+ "keyword": "Before",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 93294999
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Given ",
+ "line": 45,
+ "name": "an authenticated user \"owner\"",
+ "match": {
+ "location": "tests/api/user.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 160452750
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 46,
+ "name": "a public model \"Versioned\" created by \"owner\"",
+ "match": {
+ "location": "tests/api/model.steps.ts:127"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 54167374
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 47,
+ "name": "\"owner\" has uploaded an additional file to \"Versioned\"",
+ "match": {
+ "location": "tests/api/model-additional-file.steps.ts:106"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 17551417
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "When ",
+ "line": 48,
+ "name": "I list additional files for \"Versioned\" tagged at version 1",
+ "match": {
+ "location": "tests/api/model-additional-file.steps.ts:63"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 7069499
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Then ",
+ "line": 49,
+ "name": "the response status should be 200",
+ "match": {
+ "location": "tests/shared/common.steps.ts:13"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 30666
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 50,
+ "name": "the response body should be an array",
+ "match": {
+ "location": "tests/shared/common.steps.ts:33"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 21665
+ }
+ },
+ {
+ "keyword": "After",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 35375
+ },
+ "embeddings": [
+ {
+ "data": "U3RhdHVzOiBQQVNTRUQuIER1cmF0aW9uOjBz",
+ "mime_type": "text/plain"
+ }
+ ]
+ }
+ ],
+ "tags": [],
+ "type": "scenario"
+ },
+ {
+ "description": "",
+ "id": "model-additional-files;cannot-access-additional-files-on-a-private-model-owned-by-someone-else",
+ "keyword": "Scenario",
+ "line": 52,
+ "name": "Cannot access additional files on a private model owned by someone else",
+ "steps": [
+ {
+ "keyword": "Before",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 84619875
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Given ",
+ "line": 53,
+ "name": "an authenticated user \"owner\"",
+ "match": {
+ "location": "tests/api/user.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 148103542
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 54,
+ "name": "a private model \"Secret Data\" created by \"owner\"",
+ "match": {
+ "location": "tests/api/model.steps.ts:145"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 59826791
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 55,
+ "name": "an authenticated user \"stranger\"",
+ "match": {
+ "location": "tests/api/user.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 135433917
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "When ",
+ "line": 56,
+ "name": "\"stranger\" lists additional files for \"Secret Data\"",
+ "match": {
+ "location": "tests/api/model-additional-file.steps.ts:76"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 6485541
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Then ",
+ "line": 57,
+ "name": "the response status should be 403",
+ "match": {
+ "location": "tests/shared/common.steps.ts:13"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 41583
+ }
+ },
+ {
+ "keyword": "After",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 32707
+ },
+ "embeddings": [
+ {
+ "data": "U3RhdHVzOiBQQVNTRUQuIER1cmF0aW9uOjBz",
+ "mime_type": "text/plain"
+ }
+ ]
+ }
+ ],
+ "tags": [],
+ "type": "scenario"
+ },
+ {
+ "description": "",
+ "id": "model-additional-files;owner-can-delete-an-additional-file",
+ "keyword": "Scenario",
+ "line": 60,
+ "name": "Owner can delete an additional file",
+ "steps": [
+ {
+ "keyword": "Before",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 87598791
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Given ",
+ "line": 61,
+ "name": "an authenticated user \"owner\"",
+ "match": {
+ "location": "tests/api/user.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 143538416
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 62,
+ "name": "a public model \"Removable\" created by \"owner\"",
+ "match": {
+ "location": "tests/api/model.steps.ts:127"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 50977333
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 63,
+ "name": "\"owner\" has uploaded an additional file \"att-1\" to \"Removable\"",
+ "match": {
+ "location": "tests/api/model-additional-file.steps.ts:123"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 16545125
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "When ",
+ "line": 64,
+ "name": "\"owner\" deletes additional file \"att-1\" from \"Removable\"",
+ "match": {
+ "location": "tests/api/model-additional-file.steps.ts:142"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 8608957
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Then ",
+ "line": 65,
+ "name": "the response status should be 204",
+ "match": {
+ "location": "tests/shared/common.steps.ts:13"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 31332
+ }
+ },
+ {
+ "keyword": "After",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 42209
+ },
+ "embeddings": [
+ {
+ "data": "U3RhdHVzOiBQQVNTRUQuIER1cmF0aW9uOjBz",
+ "mime_type": "text/plain"
+ }
+ ]
+ }
+ ],
+ "tags": [
+ {
+ "name": "@smoke",
+ "line": 59
+ }
+ ],
+ "type": "scenario"
+ },
+ {
+ "description": "",
+ "id": "model-additional-files;anonymous-viewer-cannot-delete-an-additional-file",
+ "keyword": "Scenario",
+ "line": 67,
+ "name": "Anonymous viewer cannot delete an additional file",
+ "steps": [
+ {
+ "keyword": "Before",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 83301875
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Given ",
+ "line": 68,
+ "name": "an authenticated user \"owner\"",
+ "match": {
+ "location": "tests/api/user.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 141854583
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 69,
+ "name": "a public model \"Public Attachments\" created by \"owner\"",
+ "match": {
+ "location": "tests/api/model.steps.ts:127"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 45696667
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 70,
+ "name": "\"owner\" has uploaded an additional file \"att-anon\" to \"Public Attachments\"",
+ "match": {
+ "location": "tests/api/model-additional-file.steps.ts:123"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 16377583
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "When ",
+ "line": 71,
+ "name": "an anonymous viewer deletes additional file \"att-anon\" from \"Public Attachments\"",
+ "match": {
+ "location": "tests/api/model-additional-file.steps.ts:161"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 747957
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Then ",
+ "line": 72,
+ "name": "the response status should be 401",
+ "match": {
+ "location": "tests/shared/common.steps.ts:13"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 46457
+ }
+ },
+ {
+ "keyword": "After",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 52208
+ },
+ "embeddings": [
+ {
+ "data": "U3RhdHVzOiBQQVNTRUQuIER1cmF0aW9uOjBz",
+ "mime_type": "text/plain"
+ }
+ ]
+ }
+ ],
+ "tags": [],
+ "type": "scenario"
+ },
+ {
+ "description": "",
+ "id": "model-additional-files;non-author-cannot-delete-an-additional-file",
+ "keyword": "Scenario",
+ "line": 74,
+ "name": "Non-author cannot delete an additional file",
+ "steps": [
+ {
+ "keyword": "Before",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 81479166
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Given ",
+ "line": 75,
+ "name": "an authenticated user \"owner\"",
+ "match": {
+ "location": "tests/api/user.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 143300583
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 76,
+ "name": "a public model \"Guarded Attachments\" created by \"owner\"",
+ "match": {
+ "location": "tests/api/model.steps.ts:127"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 62421750
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 77,
+ "name": "\"owner\" has uploaded an additional file \"att-guarded\" to \"Guarded Attachments\"",
+ "match": {
+ "location": "tests/api/model-additional-file.steps.ts:123"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 18203249
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 78,
+ "name": "an authenticated user \"stranger\"",
+ "match": {
+ "location": "tests/api/user.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 137122374
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "When ",
+ "line": 79,
+ "name": "\"stranger\" deletes additional file \"att-guarded\" from \"Guarded Attachments\"",
+ "match": {
+ "location": "tests/api/model-additional-file.steps.ts:142"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 5411541
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Then ",
+ "line": 80,
+ "name": "the response status should be 403",
+ "match": {
+ "location": "tests/shared/common.steps.ts:13"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 31458
+ }
+ },
+ {
+ "keyword": "After",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 35667
+ },
+ "embeddings": [
+ {
+ "data": "U3RhdHVzOiBQQVNTRUQuIER1cmF0aW9uOjBz",
+ "mime_type": "text/plain"
+ }
+ ]
+ }
+ ],
+ "tags": [],
+ "type": "scenario"
+ }
+ ],
+ "id": "model-additional-files",
+ "line": 1,
+ "keyword": "Feature",
+ "name": "Model Additional Files",
+ "tags": [],
+ "uri": "tests/api/model-additional-file.feature"
+ },
+ {
+ "description": " As a model owner\n I want to manage contributors and ownership\n So that I can collaborate on models with other users",
+ "elements": [
+ {
+ "description": "",
+ "id": "model-authors;list-authors-of-a-model",
+ "keyword": "Scenario",
+ "line": 6,
+ "name": "List authors of a model",
+ "steps": [
+ {
+ "keyword": "Before",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 81477292
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Given ",
+ "line": 7,
+ "name": "an authenticated user",
+ "match": {
+ "location": "tests/api/user.steps.ts:11"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 140118458
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 8,
+ "name": "a public model \"Authored Model\" created by the current user",
+ "match": {
+ "location": "tests/api/model.steps.ts:118"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 45349834
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "When ",
+ "line": 9,
+ "name": "I list authors of the model \"Authored Model\"",
+ "match": {
+ "location": "tests/api/model-author.steps.ts:13"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 6524667
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Then ",
+ "line": 10,
+ "name": "the response status should be 200",
+ "match": {
+ "location": "tests/shared/common.steps.ts:13"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 42082
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 11,
+ "name": "the response body should be an array",
+ "match": {
+ "location": "tests/shared/common.steps.ts:33"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 37624
+ }
+ },
+ {
+ "keyword": "After",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 48625
+ },
+ "embeddings": [
+ {
+ "data": "U3RhdHVzOiBQQVNTRUQuIER1cmF0aW9uOjBz",
+ "mime_type": "text/plain"
+ }
+ ]
+ }
+ ],
+ "tags": [],
+ "type": "scenario"
+ },
+ {
+ "description": "",
+ "id": "model-authors;add-a-contributor-to-a-model",
+ "keyword": "Scenario",
+ "line": 13,
+ "name": "Add a contributor to a model",
+ "steps": [
+ {
+ "keyword": "Before",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 81249374
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Given ",
+ "line": 14,
+ "name": "an authenticated user \"owner\"",
+ "match": {
+ "location": "tests/api/user.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 140663083
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 15,
+ "name": "a public model \"Collab Model\" created by \"owner\"",
+ "match": {
+ "location": "tests/api/model.steps.ts:127"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 44834665
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 16,
+ "name": "an authenticated user \"contributor\"",
+ "match": {
+ "location": "tests/api/user.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 135069332
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "When ",
+ "line": 17,
+ "name": "\"owner\" adds \"contributor\" as a contributor to \"Collab Model\"",
+ "match": {
+ "location": "tests/api/model-author.steps.ts:26"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 11410332
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Then ",
+ "line": 18,
+ "name": "the response status should be 201",
+ "match": {
+ "location": "tests/shared/common.steps.ts:13"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 43458
+ }
+ },
+ {
+ "keyword": "After",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 38250
+ },
+ "embeddings": [
+ {
+ "data": "U3RhdHVzOiBQQVNTRUQuIER1cmF0aW9uOjBz",
+ "mime_type": "text/plain"
+ }
+ ]
+ }
+ ],
+ "tags": [],
+ "type": "scenario"
+ },
+ {
+ "description": "",
+ "id": "model-authors;contributor-appears-in-the-author-list",
+ "keyword": "Scenario",
+ "line": 20,
+ "name": "Contributor appears in the author list",
+ "steps": [
+ {
+ "keyword": "Before",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 77964874
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Given ",
+ "line": 21,
+ "name": "an authenticated user \"owner\"",
+ "match": {
+ "location": "tests/api/user.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 156896541
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 22,
+ "name": "a public model \"Team Model\" created by \"owner\"",
+ "match": {
+ "location": "tests/api/model.steps.ts:127"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 46781250
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 23,
+ "name": "an authenticated user \"contributor\"",
+ "match": {
+ "location": "tests/api/user.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 143925291
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 24,
+ "name": "\"owner\" has added \"contributor\" as a contributor to \"Team Model\"",
+ "match": {
+ "location": "tests/api/model-author.steps.ts:46"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 18427332
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "When ",
+ "line": 25,
+ "name": "I list authors of the model \"Team Model\"",
+ "match": {
+ "location": "tests/api/model-author.steps.ts:13"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 5054791
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Then ",
+ "line": 26,
+ "name": "the response status should be 200",
+ "match": {
+ "location": "tests/shared/common.steps.ts:13"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 28833
+ }
+ },
+ {
+ "keyword": "After",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 40667
+ },
+ "embeddings": [
+ {
+ "data": "U3RhdHVzOiBQQVNTRUQuIER1cmF0aW9uOjBz",
+ "mime_type": "text/plain"
+ }
+ ]
+ }
+ ],
+ "tags": [],
+ "type": "scenario"
+ },
+ {
+ "description": "",
+ "id": "model-authors;remove-a-contributor-from-a-model",
+ "keyword": "Scenario",
+ "line": 28,
+ "name": "Remove a contributor from a model",
+ "steps": [
+ {
+ "keyword": "Before",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 86795874
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Given ",
+ "line": 29,
+ "name": "an authenticated user \"owner\"",
+ "match": {
+ "location": "tests/api/user.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 146464082
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 30,
+ "name": "a public model \"Shrinking Team\" created by \"owner\"",
+ "match": {
+ "location": "tests/api/model.steps.ts:127"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 44596375
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 31,
+ "name": "an authenticated user \"contributor\"",
+ "match": {
+ "location": "tests/api/user.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 153864708
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 32,
+ "name": "\"owner\" has added \"contributor\" as a contributor to \"Shrinking Team\"",
+ "match": {
+ "location": "tests/api/model-author.steps.ts:46"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 10099666
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "When ",
+ "line": 33,
+ "name": "\"owner\" removes \"contributor\" from \"Shrinking Team\"",
+ "match": {
+ "location": "tests/api/model-author.steps.ts:69"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 7016666
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Then ",
+ "line": 34,
+ "name": "the response status should be 204",
+ "match": {
+ "location": "tests/shared/common.steps.ts:13"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 28500
+ }
+ },
+ {
+ "keyword": "After",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 37667
+ },
+ "embeddings": [
+ {
+ "data": "U3RhdHVzOiBQQVNTRUQuIER1cmF0aW9uOjBz",
+ "mime_type": "text/plain"
+ }
+ ]
+ }
+ ],
+ "tags": [],
+ "type": "scenario"
+ },
+ {
+ "description": "",
+ "id": "model-authors;cannot-remove-the-owner",
+ "keyword": "Scenario",
+ "line": 36,
+ "name": "Cannot remove the owner",
+ "steps": [
+ {
+ "keyword": "Before",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 83875458
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Given ",
+ "line": 37,
+ "name": "an authenticated user \"owner\"",
+ "match": {
+ "location": "tests/api/user.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 146736625
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 38,
+ "name": "a public model \"Owner Model\" created by \"owner\"",
+ "match": {
+ "location": "tests/api/model.steps.ts:127"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 51434208
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "When ",
+ "line": 39,
+ "name": "\"owner\" removes \"owner\" from \"Owner Model\"",
+ "match": {
+ "location": "tests/api/model-author.steps.ts:69"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 6263708
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Then ",
+ "line": 40,
+ "name": "the response status should be 403",
+ "match": {
+ "location": "tests/shared/common.steps.ts:13"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 50499
+ }
+ },
+ {
+ "keyword": "After",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 48374
+ },
+ "embeddings": [
+ {
+ "data": "U3RhdHVzOiBQQVNTRUQuIER1cmF0aW9uOjBz",
+ "mime_type": "text/plain"
+ }
+ ]
+ }
+ ],
+ "tags": [],
+ "type": "scenario"
+ },
+ {
+ "description": "",
+ "id": "model-authors;transfer-ownership-to-a-contributor",
+ "keyword": "Scenario",
+ "line": 42,
+ "name": "Transfer ownership to a contributor",
+ "steps": [
+ {
+ "keyword": "Before",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 121928916
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Given ",
+ "line": 43,
+ "name": "an authenticated user \"owner\"",
+ "match": {
+ "location": "tests/api/user.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 162036667
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 44,
+ "name": "a public model \"Transfer Model\" created by \"owner\"",
+ "match": {
+ "location": "tests/api/model.steps.ts:127"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 50682082
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 45,
+ "name": "an authenticated user \"new-owner\"",
+ "match": {
+ "location": "tests/api/user.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 143955957
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 46,
+ "name": "\"owner\" has added \"new-owner\" as a contributor to \"Transfer Model\"",
+ "match": {
+ "location": "tests/api/model-author.steps.ts:46"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 17102082
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "When ",
+ "line": 47,
+ "name": "\"owner\" transfers ownership of \"Transfer Model\" to \"new-owner\"",
+ "match": {
+ "location": "tests/api/model-author.steps.ts:83"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 7843166
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Then ",
+ "line": 48,
+ "name": "the response status should be 204",
+ "match": {
+ "location": "tests/shared/common.steps.ts:13"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 37624
+ }
+ },
+ {
+ "keyword": "After",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 46583
+ },
+ "embeddings": [
+ {
+ "data": "U3RhdHVzOiBQQVNTRUQuIER1cmF0aW9uOjBz",
+ "mime_type": "text/plain"
+ }
+ ]
+ }
+ ],
+ "tags": [],
+ "type": "scenario"
+ },
+ {
+ "description": "",
+ "id": "model-authors;cannot-transfer-ownership-to-a-non-author",
+ "keyword": "Scenario",
+ "line": 50,
+ "name": "Cannot transfer ownership to a non-author",
+ "steps": [
+ {
+ "keyword": "Before",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 88732084
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Given ",
+ "line": 51,
+ "name": "an authenticated user \"owner\"",
+ "match": {
+ "location": "tests/api/user.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 148174291
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 52,
+ "name": "a public model \"No Transfer\" created by \"owner\"",
+ "match": {
+ "location": "tests/api/model.steps.ts:127"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 46561583
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 53,
+ "name": "an authenticated user \"outsider\"",
+ "match": {
+ "location": "tests/api/user.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 149232374
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "When ",
+ "line": 54,
+ "name": "\"owner\" transfers ownership of \"No Transfer\" to \"outsider\"",
+ "match": {
+ "location": "tests/api/model-author.steps.ts:83"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 8645083
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Then ",
+ "line": 55,
+ "name": "the response status should be 404",
+ "match": {
+ "location": "tests/shared/common.steps.ts:13"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 28833
+ }
+ },
+ {
+ "keyword": "After",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 29708
+ },
+ "embeddings": [
+ {
+ "data": "U3RhdHVzOiBQQVNTRUQuIER1cmF0aW9uOjBz",
+ "mime_type": "text/plain"
+ }
+ ]
+ }
+ ],
+ "tags": [],
+ "type": "scenario"
+ },
+ {
+ "description": "",
+ "id": "model-authors;non-owner-cannot-add-contributors",
+ "keyword": "Scenario",
+ "line": 57,
+ "name": "Non-owner cannot add contributors",
+ "steps": [
+ {
+ "keyword": "Before",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 85961290
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Given ",
+ "line": 58,
+ "name": "an authenticated user \"owner\"",
+ "match": {
+ "location": "tests/api/user.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 153918792
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 59,
+ "name": "a public model \"Restricted Model\" created by \"owner\"",
+ "match": {
+ "location": "tests/api/model.steps.ts:127"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 49165167
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 60,
+ "name": "an authenticated user \"contributor\"",
+ "match": {
+ "location": "tests/api/user.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 143511749
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 61,
+ "name": "\"owner\" has added \"contributor\" as a contributor to \"Restricted Model\"",
+ "match": {
+ "location": "tests/api/model-author.steps.ts:46"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 11453333
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 62,
+ "name": "an authenticated user \"intruder\"",
+ "match": {
+ "location": "tests/api/user.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 150704250
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "When ",
+ "line": 63,
+ "name": "\"contributor\" adds \"intruder\" as a contributor to \"Restricted Model\"",
+ "match": {
+ "location": "tests/api/model-author.steps.ts:26"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 5555666
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Then ",
+ "line": 64,
+ "name": "the response status should be 403",
+ "match": {
+ "location": "tests/shared/common.steps.ts:13"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 30082
+ }
+ },
+ {
+ "keyword": "After",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 30208
+ },
+ "embeddings": [
+ {
+ "data": "U3RhdHVzOiBQQVNTRUQuIER1cmF0aW9uOjBz",
+ "mime_type": "text/plain"
+ }
+ ]
+ }
+ ],
+ "tags": [],
+ "type": "scenario"
+ },
+ {
+ "description": "",
+ "id": "model-authors;list-models-by-user",
+ "keyword": "Scenario",
+ "line": 66,
+ "name": "List models by user",
+ "steps": [
+ {
+ "keyword": "Before",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 86429667
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Given ",
+ "line": 67,
+ "name": "an authenticated user \"author\"",
+ "match": {
+ "location": "tests/api/user.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 146000625
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 68,
+ "name": "a public model \"Author's Model\" created by \"author\"",
+ "match": {
+ "location": "tests/api/model.steps.ts:127"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 50917541
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "When ",
+ "line": 69,
+ "name": "I list models by user \"author\"",
+ "match": {
+ "location": "tests/api/model-author.steps.ts:98"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 2727249
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Then ",
+ "line": 70,
+ "name": "the response status should be 200",
+ "match": {
+ "location": "tests/shared/common.steps.ts:13"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 31833
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 71,
+ "name": "the response body should have property \"data\" as an array",
+ "match": {
+ "location": "tests/shared/common.steps.ts:41"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 35167
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 72,
+ "name": "the response body property \"data\" should have length 1",
+ "match": {
+ "location": "tests/shared/common.steps.ts:49"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 27583
+ }
+ },
+ {
+ "keyword": "After",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 38624
+ },
+ "embeddings": [
+ {
+ "data": "U3RhdHVzOiBQQVNTRUQuIER1cmF0aW9uOjBz",
+ "mime_type": "text/plain"
+ }
+ ]
+ }
+ ],
+ "tags": [],
+ "type": "scenario"
+ },
+ {
+ "description": "",
+ "id": "model-authors;cannot-add-the-same-contributor-twice",
+ "keyword": "Scenario",
+ "line": 74,
+ "name": "Cannot add the same contributor twice",
+ "steps": [
+ {
+ "keyword": "Before",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 84521082
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Given ",
+ "line": 75,
+ "name": "an authenticated user \"owner\"",
+ "match": {
+ "location": "tests/api/user.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 159950417
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 76,
+ "name": "a public model \"Dup Model\" created by \"owner\"",
+ "match": {
+ "location": "tests/api/model.steps.ts:127"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 47957582
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 77,
+ "name": "an authenticated user \"contributor\"",
+ "match": {
+ "location": "tests/api/user.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 138129874
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 78,
+ "name": "\"owner\" has added \"contributor\" as a contributor to \"Dup Model\"",
+ "match": {
+ "location": "tests/api/model-author.steps.ts:46"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 15507874
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "When ",
+ "line": 79,
+ "name": "\"owner\" adds \"contributor\" as a contributor to \"Dup Model\"",
+ "match": {
+ "location": "tests/api/model-author.steps.ts:26"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 5226875
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Then ",
+ "line": 80,
+ "name": "the response status should be 409",
+ "match": {
+ "location": "tests/shared/common.steps.ts:13"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 45374
+ }
+ },
+ {
+ "keyword": "After",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 54665
+ },
+ "embeddings": [
+ {
+ "data": "U3RhdHVzOiBQQVNTRUQuIER1cmF0aW9uOjBz",
+ "mime_type": "text/plain"
+ }
+ ]
+ }
+ ],
+ "tags": [],
+ "type": "scenario"
+ }
+ ],
+ "id": "model-authors",
+ "line": 1,
+ "keyword": "Feature",
+ "name": "Model Authors",
+ "tags": [],
+ "uri": "tests/api/model-author.feature"
+ },
+ {
+ "description": " As an authenticated user\n I want to create and edit model drafts\n So that I can prepare a model before publishing",
+ "elements": [
+ {
+ "description": "",
+ "id": "model-drafts;create-an-empty-draft",
+ "keyword": "Scenario",
+ "line": 6,
+ "name": "Create an empty draft",
+ "steps": [
+ {
+ "keyword": "Before",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 90759625
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Given ",
+ "line": 7,
+ "name": "an authenticated user",
+ "match": {
+ "location": "tests/api/user.steps.ts:11"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 144242207
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "When ",
+ "line": 8,
+ "name": "I create an empty draft",
+ "match": {
+ "location": "tests/api/model-draft.steps.ts:50"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 6075209
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Then ",
+ "line": 9,
+ "name": "the response status should be 201",
+ "match": {
+ "location": "tests/shared/common.steps.ts:13"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 32666
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 10,
+ "name": "the response body should have property \"id\"",
+ "match": {
+ "location": "tests/shared/common.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 32957
+ }
+ },
+ {
+ "keyword": "After",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 30749
+ },
+ "embeddings": [
+ {
+ "data": "U3RhdHVzOiBQQVNTRUQuIER1cmF0aW9uOjBz",
+ "mime_type": "text/plain"
+ }
+ ]
+ }
+ ],
+ "tags": [],
+ "type": "scenario"
+ },
+ {
+ "description": "",
+ "id": "model-drafts;drafts-require-authentication",
+ "keyword": "Scenario",
+ "line": 12,
+ "name": "Drafts require authentication",
+ "steps": [
+ {
+ "keyword": "Before",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 84972667
+ }
+ },
+ {
+ "arguments": [
+ {
+ "content": "{}",
+ "line": 14
+ }
+ ],
+ "keyword": "When ",
+ "line": 13,
+ "name": "I send a POST request to \"/api/v1/model-drafts\" with JSON body:",
+ "match": {
+ "location": "tests/api/model.steps.ts:91"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 1060084
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Then ",
+ "line": 17,
+ "name": "the response status should be 401",
+ "match": {
+ "location": "tests/shared/common.steps.ts:13"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 24250
+ }
+ },
+ {
+ "keyword": "After",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 25625
+ },
+ "embeddings": [
+ {
+ "data": "U3RhdHVzOiBQQVNTRUQuIER1cmF0aW9uOjBz",
+ "mime_type": "text/plain"
+ }
+ ]
+ }
+ ],
+ "tags": [],
+ "type": "scenario"
+ },
+ {
+ "description": "",
+ "id": "model-drafts;patch-a-draft-with-metadata",
+ "keyword": "Scenario",
+ "line": 19,
+ "name": "Patch a draft with metadata",
+ "steps": [
+ {
+ "keyword": "Before",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 82738707
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Given ",
+ "line": 20,
+ "name": "an authenticated user",
+ "match": {
+ "location": "tests/api/user.steps.ts:11"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 147188124
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 21,
+ "name": "an empty draft",
+ "match": {
+ "location": "tests/api/model-draft.steps.ts:65"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 6319292
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "When ",
+ "line": 22,
+ "name": "I patch the draft with title \"My Draft\" and visibility \"private\"",
+ "match": {
+ "location": "tests/api/model-draft.steps.ts:106"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 3911833
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Then ",
+ "line": 23,
+ "name": "the response status should be 204",
+ "match": {
+ "location": "tests/shared/common.steps.ts:13"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 29792
+ }
+ },
+ {
+ "keyword": "After",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 40500
+ },
+ "embeddings": [
+ {
+ "data": "U3RhdHVzOiBQQVNTRUQuIER1cmF0aW9uOjBz",
+ "mime_type": "text/plain"
+ }
+ ]
+ }
+ ],
+ "tags": [],
+ "type": "scenario"
+ },
+ {
+ "description": "",
+ "id": "model-drafts;read-back-a-draft-after-patching-it",
+ "keyword": "Scenario",
+ "line": 25,
+ "name": "Read back a draft after patching it",
+ "steps": [
+ {
+ "keyword": "Before",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 82415541
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Given ",
+ "line": 26,
+ "name": "an authenticated user",
+ "match": {
+ "location": "tests/api/user.steps.ts:11"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 141904375
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 27,
+ "name": "an empty draft",
+ "match": {
+ "location": "tests/api/model-draft.steps.ts:65"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 5777624
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "When ",
+ "line": 28,
+ "name": "I patch the draft with title \"Readable Draft\" and visibility \"public\"",
+ "match": {
+ "location": "tests/api/model-draft.steps.ts:106"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 3702083
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 29,
+ "name": "I get the draft",
+ "match": {
+ "location": "tests/api/model-draft.steps.ts:120"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 7987208
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Then ",
+ "line": 30,
+ "name": "the response status should be 200",
+ "match": {
+ "location": "tests/shared/common.steps.ts:13"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 46000
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 31,
+ "name": "the response body property \"id\" should not be empty",
+ "match": {
+ "location": "tests/api/model-draft.steps.ts:186"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 42957
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 32,
+ "name": "the draft response should have data title equal to \"Readable Draft\"",
+ "match": {
+ "location": "tests/api/model-draft.steps.ts:194"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 28084
+ }
+ },
+ {
+ "keyword": "After",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 52541
+ },
+ "embeddings": [
+ {
+ "data": "U3RhdHVzOiBQQVNTRUQuIER1cmF0aW9uOjBz",
+ "mime_type": "text/plain"
+ }
+ ]
+ }
+ ],
+ "tags": [],
+ "type": "scenario"
+ },
+ {
+ "description": "",
+ "id": "model-drafts;list-drafts-for-the-current-user",
+ "keyword": "Scenario",
+ "line": 34,
+ "name": "List drafts for the current user",
+ "steps": [
+ {
+ "keyword": "Before",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 79643959
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Given ",
+ "line": 35,
+ "name": "an authenticated user",
+ "match": {
+ "location": "tests/api/user.steps.ts:11"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 153285041
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 36,
+ "name": "an empty draft",
+ "match": {
+ "location": "tests/api/model-draft.steps.ts:65"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 8366915
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "When ",
+ "line": 37,
+ "name": "I list my drafts",
+ "match": {
+ "location": "tests/api/model-draft.steps.ts:130"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 10732332
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Then ",
+ "line": 38,
+ "name": "the response status should be 200",
+ "match": {
+ "location": "tests/shared/common.steps.ts:13"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 52458
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 39,
+ "name": "the response body should have property \"data\" as an array",
+ "match": {
+ "location": "tests/shared/common.steps.ts:41"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 50417
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 40,
+ "name": "the response body property \"data\" should have length 1",
+ "match": {
+ "location": "tests/shared/common.steps.ts:49"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 45958
+ }
+ },
+ {
+ "keyword": "After",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 55958
+ },
+ "embeddings": [
+ {
+ "data": "U3RhdHVzOiBQQVNTRUQuIER1cmF0aW9uOjBz",
+ "mime_type": "text/plain"
+ }
+ ]
+ }
+ ],
+ "tags": [],
+ "type": "scenario"
+ },
+ {
+ "description": "",
+ "id": "model-drafts;another-user-cannot-read-my-draft",
+ "keyword": "Scenario",
+ "line": 42,
+ "name": "Another user cannot read my draft",
+ "steps": [
+ {
+ "keyword": "Before",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 87016583
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Given ",
+ "line": 43,
+ "name": "an authenticated user \"owner\"",
+ "match": {
+ "location": "tests/api/user.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 148479166
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 44,
+ "name": "\"owner\" creates an empty draft",
+ "match": {
+ "location": "tests/api/model-draft.steps.ts:72"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 5580207
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 45,
+ "name": "an authenticated user \"stranger\"",
+ "match": {
+ "location": "tests/api/user.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 136302584
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "When ",
+ "line": 46,
+ "name": "\"stranger\" gets the draft owned by \"owner\"",
+ "match": {
+ "location": "tests/api/model-draft.steps.ts:139"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 4664208
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Then ",
+ "line": 47,
+ "name": "the response status should be 403",
+ "match": {
+ "location": "tests/shared/common.steps.ts:13"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 35250
+ }
+ },
+ {
+ "keyword": "After",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 48457
+ },
+ "embeddings": [
+ {
+ "data": "U3RhdHVzOiBQQVNTRUQuIER1cmF0aW9uOjBz",
+ "mime_type": "text/plain"
+ }
+ ]
+ }
+ ],
+ "tags": [],
+ "type": "scenario"
+ },
+ {
+ "description": "",
+ "id": "model-drafts;abandon-a-draft",
+ "keyword": "Scenario",
+ "line": 49,
+ "name": "Abandon a draft",
+ "steps": [
+ {
+ "keyword": "Before",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 95606875
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Given ",
+ "line": 50,
+ "name": "an authenticated user",
+ "match": {
+ "location": "tests/api/user.steps.ts:11"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 145478832
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 51,
+ "name": "an empty draft",
+ "match": {
+ "location": "tests/api/model-draft.steps.ts:65"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 6241833
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "When ",
+ "line": 52,
+ "name": "I abandon the draft",
+ "match": {
+ "location": "tests/api/model-draft.steps.ts:152"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 6638958
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Then ",
+ "line": 53,
+ "name": "the response status should be 204",
+ "match": {
+ "location": "tests/shared/common.steps.ts:13"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 41916
+ }
+ },
+ {
+ "keyword": "After",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 38500
+ },
+ "embeddings": [
+ {
+ "data": "U3RhdHVzOiBQQVNTRUQuIER1cmF0aW9uOjBz",
+ "mime_type": "text/plain"
+ }
+ ]
+ }
+ ],
+ "tags": [],
+ "type": "scenario"
+ },
+ {
+ "description": "",
+ "id": "model-drafts;cannot-publish-a-draft-without-a-primary-file",
+ "keyword": "Scenario",
+ "line": 55,
+ "name": "Cannot publish a draft without a primary file",
+ "steps": [
+ {
+ "keyword": "Before",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 79576333
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Given ",
+ "line": 56,
+ "name": "an authenticated user",
+ "match": {
+ "location": "tests/api/user.steps.ts:11"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 146110041
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 57,
+ "name": "an empty draft",
+ "match": {
+ "location": "tests/api/model-draft.steps.ts:65"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 5417584
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "When ",
+ "line": 58,
+ "name": "I patch the draft with title \"No File Draft\" and visibility \"public\"",
+ "match": {
+ "location": "tests/api/model-draft.steps.ts:106"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 4216749
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 59,
+ "name": "I publish the draft",
+ "match": {
+ "location": "tests/api/model-draft.steps.ts:176"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 3011082
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Then ",
+ "line": 60,
+ "name": "the response status should be 409",
+ "match": {
+ "location": "tests/shared/common.steps.ts:13"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 22875
+ }
+ },
+ {
+ "keyword": "After",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 22582
+ },
+ "embeddings": [
+ {
+ "data": "U3RhdHVzOiBQQVNTRUQuIER1cmF0aW9uOjBz",
+ "mime_type": "text/plain"
+ }
+ ]
+ }
+ ],
+ "tags": [],
+ "type": "scenario"
+ },
+ {
+ "description": "",
+ "id": "model-drafts;publishing-a-complete-draft-creates-a-model",
+ "keyword": "Scenario",
+ "line": 62,
+ "name": "Publishing a complete draft creates a model",
+ "steps": [
+ {
+ "keyword": "Before",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 79254750
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Given ",
+ "line": 63,
+ "name": "an authenticated user",
+ "match": {
+ "location": "tests/api/user.steps.ts:11"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 141130500
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 64,
+ "name": "an empty draft",
+ "match": {
+ "location": "tests/api/model-draft.steps.ts:65"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 5369334
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "When ",
+ "line": 65,
+ "name": "I patch the draft with title \"Complete Draft\" and visibility \"public\"",
+ "match": {
+ "location": "tests/api/model-draft.steps.ts:106"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 3734249
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 66,
+ "name": "I upload a primary file to the draft",
+ "match": {
+ "location": "tests/api/model-draft.steps.ts:162"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 8659583
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 67,
+ "name": "I publish the draft",
+ "match": {
+ "location": "tests/api/model-draft.steps.ts:176"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 29024625
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Then ",
+ "line": 68,
+ "name": "the response status should be 201",
+ "match": {
+ "location": "tests/shared/common.steps.ts:13"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 32459
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 69,
+ "name": "the response body should have property \"modelId\"",
+ "match": {
+ "location": "tests/shared/common.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 61208
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 70,
+ "name": "the response body should have property \"versionNumber\"",
+ "match": {
+ "location": "tests/shared/common.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 11082
+ }
+ },
+ {
+ "keyword": "After",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 31166
+ },
+ "embeddings": [
+ {
+ "data": "U3RhdHVzOiBQQVNTRUQuIER1cmF0aW9uOjBz",
+ "mime_type": "text/plain"
+ }
+ ]
+ }
+ ],
+ "tags": [],
+ "type": "scenario"
+ },
+ {
+ "description": "",
+ "id": "model-drafts;publishing-a-draft-seeded-from-an-existing-model-creates-a-new-version",
+ "keyword": "Scenario",
+ "line": 73,
+ "name": "Publishing a draft seeded from an existing model creates a new version",
+ "steps": [
+ {
+ "keyword": "Before",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 81220207
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Given ",
+ "line": 74,
+ "name": "an authenticated user",
+ "match": {
+ "location": "tests/api/user.steps.ts:11"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 143441624
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 75,
+ "name": "a public model \"Editable Model\" created by the current user",
+ "match": {
+ "location": "tests/api/model.steps.ts:118"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 44500417
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 76,
+ "name": "a draft seeded from the model \"Editable Model\"",
+ "match": {
+ "location": "tests/api/model-draft.steps.ts:86"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 19796124
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "When ",
+ "line": 77,
+ "name": "I patch the draft with title \"Editable Model v2\" and visibility \"public\"",
+ "match": {
+ "location": "tests/api/model-draft.steps.ts:106"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 5681000
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 78,
+ "name": "I publish the draft",
+ "match": {
+ "location": "tests/api/model-draft.steps.ts:176"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 23510792
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Then ",
+ "line": 79,
+ "name": "the response status should be 201",
+ "match": {
+ "location": "tests/shared/common.steps.ts:13"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 45500
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 80,
+ "name": "the response body should have property \"modelId\"",
+ "match": {
+ "location": "tests/shared/common.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 34374
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 81,
+ "name": "the response body property \"versionNumber\" should equal \"2\"",
+ "match": {
+ "location": "tests/shared/common.steps.ts:25"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 57457
+ }
+ },
+ {
+ "keyword": "After",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 32875
+ },
+ "embeddings": [
+ {
+ "data": "U3RhdHVzOiBQQVNTRUQuIER1cmF0aW9uOjBz",
+ "mime_type": "text/plain"
+ }
+ ]
+ }
+ ],
+ "tags": [
+ {
+ "name": "@smoke",
+ "line": 72
+ }
+ ],
+ "type": "scenario"
+ },
+ {
+ "description": "",
+ "id": "model-drafts;a-user-cannot-start-a-draft-targeting-another-user's-model",
+ "keyword": "Scenario",
+ "line": 87,
+ "name": "A user cannot start a draft targeting another user's model",
+ "steps": [
+ {
+ "keyword": "Before",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 83218415
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Given ",
+ "line": 88,
+ "name": "an authenticated user \"victim\"",
+ "match": {
+ "location": "tests/api/user.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 143224082
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 89,
+ "name": "a public model \"Victim Model\" created by \"victim\"",
+ "match": {
+ "location": "tests/api/model.steps.ts:127"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 44000832
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 90,
+ "name": "an authenticated user \"attacker\"",
+ "match": {
+ "location": "tests/api/user.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 139015417
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "When ",
+ "line": 91,
+ "name": "\"attacker\" creates a draft targeting the model \"Victim Model\"",
+ "match": {
+ "location": "tests/api/model-draft.steps.ts:202"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 6689666
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Then ",
+ "line": 92,
+ "name": "the response status should not be 201",
+ "match": {
+ "location": "tests/api/security.steps.ts:61"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 77042
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 93,
+ "name": "the response status should not be 200",
+ "match": {
+ "location": "tests/api/security.steps.ts:61"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 8958
+ }
+ },
+ {
+ "keyword": "After",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 30208
+ },
+ "embeddings": [
+ {
+ "data": "U3RhdHVzOiBQQVNTRUQuIER1cmF0aW9uOjBz",
+ "mime_type": "text/plain"
+ }
+ ]
+ }
+ ],
+ "tags": [
+ {
+ "name": "@security",
+ "line": 86
+ }
+ ],
+ "type": "scenario"
+ }
+ ],
+ "id": "model-drafts",
+ "line": 1,
+ "keyword": "Feature",
+ "name": "Model Drafts",
+ "tags": [],
+ "uri": "tests/api/model-draft.feature"
+ },
+ {
+ "description": " As the system\n I want to record views, runs, downloads, and shares of a model\n So that we can show engagement metrics",
+ "elements": [
+ {
+ "description": "",
+ "id": "model-interactions;anonymous-user-records-a-view",
+ "keyword": "Scenario",
+ "line": 6,
+ "name": "Anonymous user records a view",
+ "steps": [
+ {
+ "keyword": "Before",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 81088250
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Given ",
+ "line": 7,
+ "name": "an authenticated user \"owner\"",
+ "match": {
+ "location": "tests/api/user.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 141399207
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 8,
+ "name": "a public model \"Viewable\" created by \"owner\"",
+ "match": {
+ "location": "tests/api/model.steps.ts:127"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 42590457
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "When ",
+ "line": 9,
+ "name": "an anonymous viewer records a \"view\" interaction on \"Viewable\"",
+ "match": {
+ "location": "tests/api/model-interaction.steps.ts:45"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 10378708
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Then ",
+ "line": 10,
+ "name": "the response status should be 204",
+ "match": {
+ "location": "tests/shared/common.steps.ts:13"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 33541
+ }
+ },
+ {
+ "keyword": "After",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 37209
+ },
+ "embeddings": [
+ {
+ "data": "U3RhdHVzOiBQQVNTRUQuIER1cmF0aW9uOjBz",
+ "mime_type": "text/plain"
+ }
+ ]
+ }
+ ],
+ "tags": [],
+ "type": "scenario"
+ },
+ {
+ "description": "",
+ "id": "model-interactions;recording-a-run-increments-the-run-count",
+ "keyword": "Scenario",
+ "line": 12,
+ "name": "Recording a run increments the run count",
+ "steps": [
+ {
+ "keyword": "Before",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 80894958
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Given ",
+ "line": 13,
+ "name": "an authenticated user \"owner\"",
+ "match": {
+ "location": "tests/api/user.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 150891042
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 14,
+ "name": "a public model \"Runnable\" created by \"owner\"",
+ "match": {
+ "location": "tests/api/model.steps.ts:127"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 71292041
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 15,
+ "name": "an authenticated user \"viewer\"",
+ "match": {
+ "location": "tests/api/user.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 142376749
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "When ",
+ "line": 16,
+ "name": "\"viewer\" records a \"run\" interaction on \"Runnable\"",
+ "match": {
+ "location": "tests/api/model-interaction.steps.ts:58"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 14136291
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Then ",
+ "line": 17,
+ "name": "the response status should be 204",
+ "match": {
+ "location": "tests/shared/common.steps.ts:13"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 44458
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "When ",
+ "line": 18,
+ "name": "\"viewer\" gets the interactions summary for \"Runnable\"",
+ "match": {
+ "location": "tests/api/model-interaction.steps.ts:76"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 13528833
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Then ",
+ "line": 19,
+ "name": "the response status should be 200",
+ "match": {
+ "location": "tests/shared/common.steps.ts:13"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 37583
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 20,
+ "name": "the interactions summary \"runs\" should be 1",
+ "match": {
+ "location": "tests/api/model-interaction.steps.ts:89"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 43917
+ }
+ },
+ {
+ "keyword": "After",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 39333
+ },
+ "embeddings": [
+ {
+ "data": "U3RhdHVzOiBQQVNTRUQuIER1cmF0aW9uOjBz",
+ "mime_type": "text/plain"
+ }
+ ]
+ }
+ ],
+ "tags": [],
+ "type": "scenario"
+ },
+ {
+ "description": "",
+ "id": "model-interactions;recording-a-download-increments-the-download-count",
+ "keyword": "Scenario",
+ "line": 22,
+ "name": "Recording a download increments the download count",
+ "steps": [
+ {
+ "keyword": "Before",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 87679749
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Given ",
+ "line": 23,
+ "name": "an authenticated user \"owner\"",
+ "match": {
+ "location": "tests/api/user.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 143419541
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 24,
+ "name": "a public model \"Downloadable\" created by \"owner\"",
+ "match": {
+ "location": "tests/api/model.steps.ts:127"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 43280499
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 25,
+ "name": "an authenticated user \"viewer\"",
+ "match": {
+ "location": "tests/api/user.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 154113790
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "When ",
+ "line": 26,
+ "name": "\"viewer\" records a \"download\" interaction on \"Downloadable\"",
+ "match": {
+ "location": "tests/api/model-interaction.steps.ts:58"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 19634000
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 27,
+ "name": "\"viewer\" gets the interactions summary for \"Downloadable\"",
+ "match": {
+ "location": "tests/api/model-interaction.steps.ts:76"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 9771999
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Then ",
+ "line": 28,
+ "name": "the interactions summary \"downloads\" should be 1",
+ "match": {
+ "location": "tests/api/model-interaction.steps.ts:89"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 48584
+ }
+ },
+ {
+ "keyword": "After",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 40333
+ },
+ "embeddings": [
+ {
+ "data": "U3RhdHVzOiBQQVNTRUQuIER1cmF0aW9uOjBz",
+ "mime_type": "text/plain"
+ }
+ ]
+ }
+ ],
+ "tags": [],
+ "type": "scenario"
+ },
+ {
+ "description": "",
+ "id": "model-interactions;interactions-summary-returns-the-full-shape",
+ "keyword": "Scenario",
+ "line": 30,
+ "name": "Interactions summary returns the full shape",
+ "steps": [
+ {
+ "keyword": "Before",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 82578875
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Given ",
+ "line": 31,
+ "name": "an authenticated user \"owner\"",
+ "match": {
+ "location": "tests/api/user.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 142967625
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 32,
+ "name": "a public model \"Stats Model\" created by \"owner\"",
+ "match": {
+ "location": "tests/api/model.steps.ts:127"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 51325333
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "When ",
+ "line": 33,
+ "name": "\"owner\" gets the interactions summary for \"Stats Model\"",
+ "match": {
+ "location": "tests/api/model-interaction.steps.ts:76"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 7825584
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Then ",
+ "line": 34,
+ "name": "the response status should be 200",
+ "match": {
+ "location": "tests/shared/common.steps.ts:13"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 30249
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 35,
+ "name": "the response body should have property \"likes\"",
+ "match": {
+ "location": "tests/shared/common.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 59875
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 36,
+ "name": "the response body should have property \"views\"",
+ "match": {
+ "location": "tests/shared/common.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 9667
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 37,
+ "name": "the response body should have property \"runs\"",
+ "match": {
+ "location": "tests/shared/common.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 8374
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 38,
+ "name": "the response body should have property \"downloads\"",
+ "match": {
+ "location": "tests/shared/common.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 8166
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 39,
+ "name": "the response body should have property \"shares\"",
+ "match": {
+ "location": "tests/shared/common.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 7499
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 40,
+ "name": "the response body should have property \"likedByMe\"",
+ "match": {
+ "location": "tests/shared/common.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 7375
+ }
+ },
+ {
+ "keyword": "After",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 34750
+ },
+ "embeddings": [
+ {
+ "data": "U3RhdHVzOiBQQVNTRUQuIER1cmF0aW9uOjBz",
+ "mime_type": "text/plain"
+ }
+ ]
+ }
+ ],
+ "tags": [],
+ "type": "scenario"
+ },
+ {
+ "description": "",
+ "id": "model-interactions;cannot-record-an-interaction-on-a-private-model-owned-by-someone-else",
+ "keyword": "Scenario",
+ "line": 42,
+ "name": "Cannot record an interaction on a private model owned by someone else",
+ "steps": [
+ {
+ "keyword": "Before",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 75513042
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Given ",
+ "line": 43,
+ "name": "an authenticated user \"owner\"",
+ "match": {
+ "location": "tests/api/user.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 159573207
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 44,
+ "name": "a private model \"Closed\" created by \"owner\"",
+ "match": {
+ "location": "tests/api/model.steps.ts:145"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 49579832
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 45,
+ "name": "an authenticated user \"outsider\"",
+ "match": {
+ "location": "tests/api/user.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 147769041
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "When ",
+ "line": 46,
+ "name": "\"outsider\" records a \"view\" interaction on \"Closed\"",
+ "match": {
+ "location": "tests/api/model-interaction.steps.ts:58"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 8727582
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Then ",
+ "line": 47,
+ "name": "the response status should be 403",
+ "match": {
+ "location": "tests/shared/common.steps.ts:13"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 39167
+ }
+ },
+ {
+ "keyword": "After",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 51666
+ },
+ "embeddings": [
+ {
+ "data": "U3RhdHVzOiBQQVNTRUQuIER1cmF0aW9uOjBz",
+ "mime_type": "text/plain"
+ }
+ ]
+ }
+ ],
+ "tags": [],
+ "type": "scenario"
+ },
+ {
+ "description": "",
+ "id": "model-interactions;view-dedupe-is-counted-once-within-the-window",
+ "keyword": "Scenario",
+ "line": 49,
+ "name": "View dedupe is counted once within the window",
+ "steps": [
+ {
+ "keyword": "Before",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 85778207
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Given ",
+ "line": 50,
+ "name": "an authenticated user \"owner\"",
+ "match": {
+ "location": "tests/api/user.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 147994042
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 51,
+ "name": "a public model \"Deduped\" created by \"owner\"",
+ "match": {
+ "location": "tests/api/model.steps.ts:127"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 44917249
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 52,
+ "name": "an authenticated user \"viewer\"",
+ "match": {
+ "location": "tests/api/user.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 137950791
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "When ",
+ "line": 53,
+ "name": "\"viewer\" records a \"view\" interaction on \"Deduped\"",
+ "match": {
+ "location": "tests/api/model-interaction.steps.ts:58"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 17469832
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 54,
+ "name": "\"viewer\" records a \"view\" interaction on \"Deduped\"",
+ "match": {
+ "location": "tests/api/model-interaction.steps.ts:58"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 5946166
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 55,
+ "name": "\"viewer\" gets the interactions summary for \"Deduped\"",
+ "match": {
+ "location": "tests/api/model-interaction.steps.ts:76"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 5323458
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Then ",
+ "line": 56,
+ "name": "the response status should be 200",
+ "match": {
+ "location": "tests/shared/common.steps.ts:13"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 27708
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 57,
+ "name": "the interactions summary \"views\" should be 1",
+ "match": {
+ "location": "tests/api/model-interaction.steps.ts:89"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 43832
+ }
+ },
+ {
+ "keyword": "After",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 38500
+ },
+ "embeddings": [
+ {
+ "data": "U3RhdHVzOiBQQVNTRUQuIER1cmF0aW9uOjBz",
+ "mime_type": "text/plain"
+ }
+ ]
+ }
+ ],
+ "tags": [],
+ "type": "scenario"
+ },
+ {
+ "description": "",
+ "id": "model-interactions;model-card-stats-reflect-recorded-interactions",
+ "keyword": "Scenario",
+ "line": 59,
+ "name": "Model card stats reflect recorded interactions",
+ "steps": [
+ {
+ "keyword": "Before",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 128586041
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Given ",
+ "line": 60,
+ "name": "an authenticated user \"owner\"",
+ "match": {
+ "location": "tests/api/user.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 152126250
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 61,
+ "name": "a public model \"Counted\" created by \"owner\"",
+ "match": {
+ "location": "tests/api/model.steps.ts:127"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 41472250
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 62,
+ "name": "an authenticated user \"viewer\"",
+ "match": {
+ "location": "tests/api/user.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 152364584
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "When ",
+ "line": 63,
+ "name": "\"viewer\" records a \"run\" interaction on \"Counted\"",
+ "match": {
+ "location": "tests/api/model-interaction.steps.ts:58"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 20454624
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 64,
+ "name": "\"viewer\" records a \"download\" interaction on \"Counted\"",
+ "match": {
+ "location": "tests/api/model-interaction.steps.ts:58"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 6887999
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 65,
+ "name": "\"owner\" gets the card for model \"Counted\"",
+ "match": {
+ "location": "tests/api/model-interaction.steps.ts:97"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 30655458
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Then ",
+ "line": 66,
+ "name": "the response status should be 200",
+ "match": {
+ "location": "tests/shared/common.steps.ts:13"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 39333
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 67,
+ "name": "the response body path \"stats.runs\" should be 1",
+ "match": {
+ "location": "tests/shared/common.steps.ts:81"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 69000
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 68,
+ "name": "the response body path \"stats.downloads\" should be 1",
+ "match": {
+ "location": "tests/shared/common.steps.ts:81"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 17583
+ }
+ },
+ {
+ "keyword": "After",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 39667
+ },
+ "embeddings": [
+ {
+ "data": "U3RhdHVzOiBQQVNTRUQuIER1cmF0aW9uOjBz",
+ "mime_type": "text/plain"
+ }
+ ]
+ }
+ ],
+ "tags": [],
+ "type": "scenario"
+ }
+ ],
+ "id": "model-interactions",
+ "line": 1,
+ "keyword": "Feature",
+ "name": "Model Interactions",
+ "tags": [],
+ "uri": "tests/api/model-interaction.feature"
+ },
+ {
+ "description": " As a user\n I want to like models\n So that I can keep track of the models I find useful",
+ "elements": [
+ {
+ "description": "",
+ "id": "model-likes;anonymous-viewer-sees-zero-likes",
+ "keyword": "Scenario",
+ "line": 6,
+ "name": "Anonymous viewer sees zero likes",
+ "steps": [
+ {
+ "keyword": "Before",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 89234292
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Given ",
+ "line": 7,
+ "name": "an authenticated user \"owner\"",
+ "match": {
+ "location": "tests/api/user.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 148851167
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 8,
+ "name": "a public model \"Likeable\" created by \"owner\"",
+ "match": {
+ "location": "tests/api/model.steps.ts:127"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 51433042
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "When ",
+ "line": 9,
+ "name": "I get the like summary for \"Likeable\"",
+ "match": {
+ "location": "tests/api/model-like.steps.ts:73"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 24970124
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Then ",
+ "line": 10,
+ "name": "the response status should be 200",
+ "match": {
+ "location": "tests/shared/common.steps.ts:13"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 38749
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 11,
+ "name": "the like summary count should be 0",
+ "match": {
+ "location": "tests/api/model-like.steps.ts:99"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 36541
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 12,
+ "name": "the response body should have property \"likedByMe\" equal to false",
+ "match": {
+ "location": "tests/shared/common.steps.ts:73"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 24249
+ }
+ },
+ {
+ "keyword": "After",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 43334
+ },
+ "embeddings": [
+ {
+ "data": "U3RhdHVzOiBQQVNTRUQuIER1cmF0aW9uOjBz",
+ "mime_type": "text/plain"
+ }
+ ]
+ }
+ ],
+ "tags": [],
+ "type": "scenario"
+ },
+ {
+ "description": "",
+ "id": "model-likes;an-authenticated-user-likes-a-model",
+ "keyword": "Scenario",
+ "line": 14,
+ "name": "An authenticated user likes a model",
+ "steps": [
+ {
+ "keyword": "Before",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 91604999
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Given ",
+ "line": 15,
+ "name": "an authenticated user \"owner\"",
+ "match": {
+ "location": "tests/api/user.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 156803125
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 16,
+ "name": "a public model \"Liked Model\" created by \"owner\"",
+ "match": {
+ "location": "tests/api/model.steps.ts:127"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 46956624
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 17,
+ "name": "an authenticated user \"fan\"",
+ "match": {
+ "location": "tests/api/user.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 136561416
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "When ",
+ "line": 18,
+ "name": "\"fan\" likes \"Liked Model\"",
+ "match": {
+ "location": "tests/api/model-like.steps.ts:28"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 11829208
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Then ",
+ "line": 19,
+ "name": "the response status should be 204",
+ "match": {
+ "location": "tests/shared/common.steps.ts:13"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 42292
+ }
+ },
+ {
+ "keyword": "After",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 37916
+ },
+ "embeddings": [
+ {
+ "data": "U3RhdHVzOiBQQVNTRUQuIER1cmF0aW9uOjBz",
+ "mime_type": "text/plain"
+ }
+ ]
+ }
+ ],
+ "tags": [],
+ "type": "scenario"
+ },
+ {
+ "description": "",
+ "id": "model-likes;liking-a-model-is-reflected-in-the-summary",
+ "keyword": "Scenario",
+ "line": 21,
+ "name": "Liking a model is reflected in the summary",
+ "steps": [
+ {
+ "keyword": "Before",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 75039000
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Given ",
+ "line": 22,
+ "name": "an authenticated user \"owner\"",
+ "match": {
+ "location": "tests/api/user.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 142118875
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 23,
+ "name": "a public model \"Famous Model\" created by \"owner\"",
+ "match": {
+ "location": "tests/api/model.steps.ts:127"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 45254415
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 24,
+ "name": "an authenticated user \"fan\"",
+ "match": {
+ "location": "tests/api/user.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 136217375
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 25,
+ "name": "\"fan\" has liked \"Famous Model\"",
+ "match": {
+ "location": "tests/api/model-like.steps.ts:61"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 14432082
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "When ",
+ "line": 26,
+ "name": "\"fan\" gets the like summary for \"Famous Model\"",
+ "match": {
+ "location": "tests/api/model-like.steps.ts:86"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 4801708
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Then ",
+ "line": 27,
+ "name": "the response status should be 200",
+ "match": {
+ "location": "tests/shared/common.steps.ts:13"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 40374
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 28,
+ "name": "the like summary count should be 1",
+ "match": {
+ "location": "tests/api/model-like.steps.ts:99"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 25625
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 29,
+ "name": "the response body should have property \"likedByMe\" equal to true",
+ "match": {
+ "location": "tests/shared/common.steps.ts:65"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 68999
+ }
+ },
+ {
+ "keyword": "After",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 34041
+ },
+ "embeddings": [
+ {
+ "data": "U3RhdHVzOiBQQVNTRUQuIER1cmF0aW9uOjBz",
+ "mime_type": "text/plain"
+ }
+ ]
+ }
+ ],
+ "tags": [],
+ "type": "scenario"
+ },
+ {
+ "description": "",
+ "id": "model-likes;liking-the-same-model-twice-does-not-double-count",
+ "keyword": "Scenario",
+ "line": 31,
+ "name": "Liking the same model twice does not double-count",
+ "steps": [
+ {
+ "keyword": "Before",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 86629750
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Given ",
+ "line": 32,
+ "name": "an authenticated user \"owner\"",
+ "match": {
+ "location": "tests/api/user.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 138856458
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 33,
+ "name": "a public model \"Idempotent\" created by \"owner\"",
+ "match": {
+ "location": "tests/api/model.steps.ts:127"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 42666583
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 34,
+ "name": "an authenticated user \"fan\"",
+ "match": {
+ "location": "tests/api/user.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 135951041
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 35,
+ "name": "\"fan\" has liked \"Idempotent\"",
+ "match": {
+ "location": "tests/api/model-like.steps.ts:61"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 13966958
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "When ",
+ "line": 36,
+ "name": "\"fan\" likes \"Idempotent\"",
+ "match": {
+ "location": "tests/api/model-like.steps.ts:28"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 5281041
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Then ",
+ "line": 37,
+ "name": "the response status should be 204",
+ "match": {
+ "location": "tests/shared/common.steps.ts:13"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 29041
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "When ",
+ "line": 38,
+ "name": "\"fan\" gets the like summary for \"Idempotent\"",
+ "match": {
+ "location": "tests/api/model-like.steps.ts:86"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 3888792
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 39,
+ "name": "the like summary count should be 1",
+ "match": {
+ "location": "tests/api/model-like.steps.ts:99"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 29582
+ }
+ },
+ {
+ "keyword": "After",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 32209
+ },
+ "embeddings": [
+ {
+ "data": "U3RhdHVzOiBQQVNTRUQuIER1cmF0aW9uOjBz",
+ "mime_type": "text/plain"
+ }
+ ]
+ }
+ ],
+ "tags": [],
+ "type": "scenario"
+ },
+ {
+ "description": "",
+ "id": "model-likes;unliking-a-model-removes-the-like",
+ "keyword": "Scenario",
+ "line": 41,
+ "name": "Unliking a model removes the like",
+ "steps": [
+ {
+ "keyword": "Before",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 90303041
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Given ",
+ "line": 42,
+ "name": "an authenticated user \"owner\"",
+ "match": {
+ "location": "tests/api/user.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 141477208
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 43,
+ "name": "a public model \"Unlikeable\" created by \"owner\"",
+ "match": {
+ "location": "tests/api/model.steps.ts:127"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 41117417
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 44,
+ "name": "an authenticated user \"fan\"",
+ "match": {
+ "location": "tests/api/user.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 137605625
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 45,
+ "name": "\"fan\" has liked \"Unlikeable\"",
+ "match": {
+ "location": "tests/api/model-like.steps.ts:61"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 14238666
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "When ",
+ "line": 46,
+ "name": "\"fan\" unlikes \"Unlikeable\"",
+ "match": {
+ "location": "tests/api/model-like.steps.ts:48"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 7725040
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Then ",
+ "line": 47,
+ "name": "the response status should be 204",
+ "match": {
+ "location": "tests/shared/common.steps.ts:13"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 41917
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "When ",
+ "line": 48,
+ "name": "\"fan\" gets the like summary for \"Unlikeable\"",
+ "match": {
+ "location": "tests/api/model-like.steps.ts:86"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 3198124
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 49,
+ "name": "the like summary count should be 0",
+ "match": {
+ "location": "tests/api/model-like.steps.ts:99"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 21749
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 50,
+ "name": "the response body should have property \"likedByMe\" equal to false",
+ "match": {
+ "location": "tests/shared/common.steps.ts:73"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 45084
+ }
+ },
+ {
+ "keyword": "After",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 32041
+ },
+ "embeddings": [
+ {
+ "data": "U3RhdHVzOiBQQVNTRUQuIER1cmF0aW9uOjBz",
+ "mime_type": "text/plain"
+ }
+ ]
+ }
+ ],
+ "tags": [],
+ "type": "scenario"
+ },
+ {
+ "description": "",
+ "id": "model-likes;liking-requires-authentication",
+ "keyword": "Scenario",
+ "line": 52,
+ "name": "Liking requires authentication",
+ "steps": [
+ {
+ "keyword": "Before",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 79919042
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Given ",
+ "line": 53,
+ "name": "an authenticated user \"owner\"",
+ "match": {
+ "location": "tests/api/user.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 143302083
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 54,
+ "name": "a public model \"Auth Required\" created by \"owner\"",
+ "match": {
+ "location": "tests/api/model.steps.ts:127"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 43319292
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "When ",
+ "line": 55,
+ "name": "an anonymous viewer likes \"Auth Required\"",
+ "match": {
+ "location": "tests/api/model-like.steps.ts:37"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 883582
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Then ",
+ "line": 56,
+ "name": "the response status should be 401",
+ "match": {
+ "location": "tests/shared/common.steps.ts:13"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 18166
+ }
+ },
+ {
+ "keyword": "After",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 35332
+ },
+ "embeddings": [
+ {
+ "data": "U3RhdHVzOiBQQVNTRUQuIER1cmF0aW9uOjBz",
+ "mime_type": "text/plain"
+ }
+ ]
+ }
+ ],
+ "tags": [],
+ "type": "scenario"
+ },
+ {
+ "description": "",
+ "id": "model-likes;cannot-like-a-private-model-owned-by-someone-else",
+ "keyword": "Scenario",
+ "line": 58,
+ "name": "Cannot like a private model owned by someone else",
+ "steps": [
+ {
+ "keyword": "Before",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 107707708
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Given ",
+ "line": 59,
+ "name": "an authenticated user \"owner\"",
+ "match": {
+ "location": "tests/api/user.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 144042333
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 60,
+ "name": "a private model \"Secret\" created by \"owner\"",
+ "match": {
+ "location": "tests/api/model.steps.ts:145"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 42106708
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 61,
+ "name": "an authenticated user \"stranger\"",
+ "match": {
+ "location": "tests/api/user.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 133720042
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "When ",
+ "line": 62,
+ "name": "\"stranger\" likes \"Secret\"",
+ "match": {
+ "location": "tests/api/model-like.steps.ts:28"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 6815416
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Then ",
+ "line": 63,
+ "name": "the response status should be 403",
+ "match": {
+ "location": "tests/shared/common.steps.ts:13"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 26792
+ }
+ },
+ {
+ "keyword": "After",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 38042
+ },
+ "embeddings": [
+ {
+ "data": "U3RhdHVzOiBQQVNTRUQuIER1cmF0aW9uOjBz",
+ "mime_type": "text/plain"
+ }
+ ]
+ }
+ ],
+ "tags": [],
+ "type": "scenario"
+ }
+ ],
+ "id": "model-likes",
+ "line": 1,
+ "keyword": "Feature",
+ "name": "Model Likes",
+ "tags": [],
+ "uri": "tests/api/model-like.feature"
+ },
+ {
+ "description": " As a model owner\n I want to manage access permissions\n So that I can control who can read, write, or administer my models",
+ "elements": [
+ {
+ "description": "",
+ "id": "model-permissions;grant-read-permission-to-another-user",
+ "keyword": "Scenario",
+ "line": 6,
+ "name": "Grant read permission to another user",
+ "steps": [
+ {
+ "keyword": "Before",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 82726042
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Given ",
+ "line": 7,
+ "name": "an authenticated user \"owner\"",
+ "match": {
+ "location": "tests/api/user.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 142778708
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 8,
+ "name": "a private model \"Shared Model\" created by \"owner\"",
+ "match": {
+ "location": "tests/api/model.steps.ts:145"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 40903708
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 9,
+ "name": "an authenticated user \"reader\"",
+ "match": {
+ "location": "tests/api/user.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 139538416
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "When ",
+ "line": 10,
+ "name": "\"owner\" grants \"read\" permission on \"Shared Model\" to \"reader\"",
+ "match": {
+ "location": "tests/api/model-permission.steps.ts:13"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 16785125
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Then ",
+ "line": 11,
+ "name": "the response status should be 201",
+ "match": {
+ "location": "tests/shared/common.steps.ts:13"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 35249
+ }
+ },
+ {
+ "keyword": "After",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 40958
+ },
+ "embeddings": [
+ {
+ "data": "U3RhdHVzOiBQQVNTRUQuIER1cmF0aW9uOjBz",
+ "mime_type": "text/plain"
+ }
+ ]
+ }
+ ],
+ "tags": [],
+ "type": "scenario"
+ },
+ {
+ "description": "",
+ "id": "model-permissions;user-with-read-permission-can-access-a-private-model",
+ "keyword": "Scenario",
+ "line": 13,
+ "name": "User with read permission can access a private model",
+ "steps": [
+ {
+ "keyword": "Before",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 82370750
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Given ",
+ "line": 14,
+ "name": "an authenticated user \"owner\"",
+ "match": {
+ "location": "tests/api/user.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 156799250
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 15,
+ "name": "a private model \"Readable Model\" created by \"owner\"",
+ "match": {
+ "location": "tests/api/model.steps.ts:145"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 47354374
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 16,
+ "name": "an authenticated user \"reader\"",
+ "match": {
+ "location": "tests/api/user.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 150569207
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 17,
+ "name": "\"owner\" has granted \"read\" permission on \"Readable Model\" to \"reader\"",
+ "match": {
+ "location": "tests/api/model-permission.steps.ts:35"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 10268166
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "When ",
+ "line": 18,
+ "name": "\"reader\" gets the model \"Readable Model\"",
+ "match": {
+ "location": "tests/api/model.steps.ts:185"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 11246917
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Then ",
+ "line": 19,
+ "name": "the response status should be 200",
+ "match": {
+ "location": "tests/shared/common.steps.ts:13"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 33583
+ }
+ },
+ {
+ "keyword": "After",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 36207
+ },
+ "embeddings": [
+ {
+ "data": "U3RhdHVzOiBQQVNTRUQuIER1cmF0aW9uOjBz",
+ "mime_type": "text/plain"
+ }
+ ]
+ }
+ ],
+ "tags": [],
+ "type": "scenario"
+ },
+ {
+ "description": "",
+ "id": "model-permissions;user-with-write-permission-can-update-a-model",
+ "keyword": "Scenario",
+ "line": 21,
+ "name": "User with write permission can update a model",
+ "steps": [
+ {
+ "keyword": "Before",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 84927250
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Given ",
+ "line": 22,
+ "name": "an authenticated user \"owner\"",
+ "match": {
+ "location": "tests/api/user.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 158855917
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 23,
+ "name": "a public model \"Writable Model\" created by \"owner\"",
+ "match": {
+ "location": "tests/api/model.steps.ts:127"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 50089875
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 24,
+ "name": "an authenticated user \"writer\"",
+ "match": {
+ "location": "tests/api/user.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 137078084
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 25,
+ "name": "\"owner\" has granted \"write\" permission on \"Writable Model\" to \"writer\"",
+ "match": {
+ "location": "tests/api/model-permission.steps.ts:35"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 13744666
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "When ",
+ "line": 26,
+ "name": "\"writer\" updates the model \"Writable Model\" with visibility \"private\"",
+ "match": {
+ "location": "tests/api/model-permission.steps.ts:108"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 7622500
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Then ",
+ "line": 27,
+ "name": "the response status should be 204",
+ "match": {
+ "location": "tests/shared/common.steps.ts:13"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 40834
+ }
+ },
+ {
+ "keyword": "After",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 51000
+ },
+ "embeddings": [
+ {
+ "data": "U3RhdHVzOiBQQVNTRUQuIER1cmF0aW9uOjBz",
+ "mime_type": "text/plain"
+ }
+ ]
+ }
+ ],
+ "tags": [],
+ "type": "scenario"
+ },
+ {
+ "description": "",
+ "id": "model-permissions;user-with-read-permission-cannot-update-a-model",
+ "keyword": "Scenario",
+ "line": 29,
+ "name": "User with read permission cannot update a model",
+ "steps": [
+ {
+ "keyword": "Before",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 83716416
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Given ",
+ "line": 30,
+ "name": "an authenticated user \"owner\"",
+ "match": {
+ "location": "tests/api/user.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 158331833
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 31,
+ "name": "a public model \"Read Only Model\" created by \"owner\"",
+ "match": {
+ "location": "tests/api/model.steps.ts:127"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 48792874
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 32,
+ "name": "an authenticated user \"reader\"",
+ "match": {
+ "location": "tests/api/user.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 140559375
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 33,
+ "name": "\"owner\" has granted \"read\" permission on \"Read Only Model\" to \"reader\"",
+ "match": {
+ "location": "tests/api/model-permission.steps.ts:35"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 12701541
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "When ",
+ "line": 34,
+ "name": "\"reader\" updates the model \"Read Only Model\" with visibility \"private\"",
+ "match": {
+ "location": "tests/api/model-permission.steps.ts:108"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 5304000
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Then ",
+ "line": 35,
+ "name": "the response status should be 403",
+ "match": {
+ "location": "tests/shared/common.steps.ts:13"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 25750
+ }
+ },
+ {
+ "keyword": "After",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 31374
+ },
+ "embeddings": [
+ {
+ "data": "U3RhdHVzOiBQQVNTRUQuIER1cmF0aW9uOjBz",
+ "mime_type": "text/plain"
+ }
+ ]
+ }
+ ],
+ "tags": [],
+ "type": "scenario"
+ },
+ {
+ "description": "",
+ "id": "model-permissions;list-permissions-on-a-model",
+ "keyword": "Scenario",
+ "line": 37,
+ "name": "List permissions on a model",
+ "steps": [
+ {
+ "keyword": "Before",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 82599707
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Given ",
+ "line": 38,
+ "name": "an authenticated user \"owner\"",
+ "match": {
+ "location": "tests/api/user.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 145147375
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 39,
+ "name": "a private model \"Listed Model\" created by \"owner\"",
+ "match": {
+ "location": "tests/api/model.steps.ts:145"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 40183874
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 40,
+ "name": "an authenticated user \"collaborator\"",
+ "match": {
+ "location": "tests/api/user.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 134261499
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 41,
+ "name": "\"owner\" has granted \"write\" permission on \"Listed Model\" to \"collaborator\"",
+ "match": {
+ "location": "tests/api/model-permission.steps.ts:35"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 14596750
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "When ",
+ "line": 42,
+ "name": "\"owner\" lists permissions on \"Listed Model\"",
+ "match": {
+ "location": "tests/api/model-permission.steps.ts:61"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 5244750
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Then ",
+ "line": 43,
+ "name": "the response status should be 200",
+ "match": {
+ "location": "tests/shared/common.steps.ts:13"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 27792
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 44,
+ "name": "the response body should be an array",
+ "match": {
+ "location": "tests/shared/common.steps.ts:33"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 43709
+ }
+ },
+ {
+ "keyword": "After",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 37541
+ },
+ "embeddings": [
+ {
+ "data": "U3RhdHVzOiBQQVNTRUQuIER1cmF0aW9uOjBz",
+ "mime_type": "text/plain"
+ }
+ ]
+ }
+ ],
+ "tags": [],
+ "type": "scenario"
+ },
+ {
+ "description": "",
+ "id": "model-permissions;revoke-permission",
+ "keyword": "Scenario",
+ "line": 46,
+ "name": "Revoke permission",
+ "steps": [
+ {
+ "keyword": "Before",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 81906083
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Given ",
+ "line": 47,
+ "name": "an authenticated user \"owner\"",
+ "match": {
+ "location": "tests/api/user.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 152983166
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 48,
+ "name": "a private model \"Revoke Model\" created by \"owner\"",
+ "match": {
+ "location": "tests/api/model.steps.ts:145"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 51006083
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 49,
+ "name": "an authenticated user \"former\"",
+ "match": {
+ "location": "tests/api/user.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 155647666
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 50,
+ "name": "\"owner\" has granted \"read\" permission on \"Revoke Model\" to \"former\"",
+ "match": {
+ "location": "tests/api/model-permission.steps.ts:35"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 16172749
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "When ",
+ "line": 51,
+ "name": "\"owner\" revokes permission on \"Revoke Model\" from \"former\"",
+ "match": {
+ "location": "tests/api/model-permission.steps.ts:75"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 7270416
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Then ",
+ "line": 52,
+ "name": "the response status should be 204",
+ "match": {
+ "location": "tests/shared/common.steps.ts:13"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 32374
+ }
+ },
+ {
+ "keyword": "After",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 38082
+ },
+ "embeddings": [
+ {
+ "data": "U3RhdHVzOiBQQVNTRUQuIER1cmF0aW9uOjBz",
+ "mime_type": "text/plain"
+ }
+ ]
+ }
+ ],
+ "tags": [],
+ "type": "scenario"
+ },
+ {
+ "description": "",
+ "id": "model-permissions;revoked-user-can-no-longer-access-a-private-model",
+ "keyword": "Scenario",
+ "line": 54,
+ "name": "Revoked user can no longer access a private model",
+ "steps": [
+ {
+ "keyword": "Before",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 84724958
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Given ",
+ "line": 55,
+ "name": "an authenticated user \"owner\"",
+ "match": {
+ "location": "tests/api/user.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 147582500
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 56,
+ "name": "a private model \"Locked Out Model\" created by \"owner\"",
+ "match": {
+ "location": "tests/api/model.steps.ts:145"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 40940166
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 57,
+ "name": "an authenticated user \"former\"",
+ "match": {
+ "location": "tests/api/user.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 141163000
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 58,
+ "name": "\"owner\" has granted \"read\" permission on \"Locked Out Model\" to \"former\"",
+ "match": {
+ "location": "tests/api/model-permission.steps.ts:35"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 16001499
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 59,
+ "name": "\"owner\" has revoked permission on \"Locked Out Model\" from \"former\"",
+ "match": {
+ "location": "tests/api/model-permission.steps.ts:90"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 6542709
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "When ",
+ "line": 60,
+ "name": "\"former\" gets the model \"Locked Out Model\"",
+ "match": {
+ "location": "tests/api/model.steps.ts:185"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 3120292
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Then ",
+ "line": 61,
+ "name": "the response status should be 403",
+ "match": {
+ "location": "tests/shared/common.steps.ts:13"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 40791
+ }
+ },
+ {
+ "keyword": "After",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 33542
+ },
+ "embeddings": [
+ {
+ "data": "U3RhdHVzOiBQQVNTRUQuIER1cmF0aW9uOjBz",
+ "mime_type": "text/plain"
+ }
+ ]
+ }
+ ],
+ "tags": [],
+ "type": "scenario"
+ },
+ {
+ "description": "",
+ "id": "model-permissions;non-admin-cannot-grant-permissions",
+ "keyword": "Scenario",
+ "line": 63,
+ "name": "Non-admin cannot grant permissions",
+ "steps": [
+ {
+ "keyword": "Before",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 79528125
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Given ",
+ "line": 64,
+ "name": "an authenticated user \"owner\"",
+ "match": {
+ "location": "tests/api/user.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 144646250
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 65,
+ "name": "a public model \"Locked Model\" created by \"owner\"",
+ "match": {
+ "location": "tests/api/model.steps.ts:127"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 40823374
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 66,
+ "name": "an authenticated user \"intruder\"",
+ "match": {
+ "location": "tests/api/user.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 137892416
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "When ",
+ "line": 67,
+ "name": "\"intruder\" grants \"write\" permission on \"Locked Model\" to \"intruder\"",
+ "match": {
+ "location": "tests/api/model-permission.steps.ts:13"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 10059499
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Then ",
+ "line": 68,
+ "name": "the response status should be 403",
+ "match": {
+ "location": "tests/shared/common.steps.ts:13"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 38915
+ }
+ },
+ {
+ "keyword": "After",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 35625
+ },
+ "embeddings": [
+ {
+ "data": "U3RhdHVzOiBQQVNTRUQuIER1cmF0aW9uOjBz",
+ "mime_type": "text/plain"
+ }
+ ]
+ }
+ ],
+ "tags": [],
+ "type": "scenario"
+ },
+ {
+ "description": "",
+ "id": "model-permissions;cannot-grant-duplicate-permission",
+ "keyword": "Scenario",
+ "line": 70,
+ "name": "Cannot grant duplicate permission",
+ "steps": [
+ {
+ "keyword": "Before",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 83945125
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Given ",
+ "line": 71,
+ "name": "an authenticated user \"owner\"",
+ "match": {
+ "location": "tests/api/user.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 145184832
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 72,
+ "name": "a private model \"Dup Perm Model\" created by \"owner\"",
+ "match": {
+ "location": "tests/api/model.steps.ts:145"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 41263167
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 73,
+ "name": "an authenticated user \"grantee\"",
+ "match": {
+ "location": "tests/api/user.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 136259165
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 74,
+ "name": "\"owner\" has granted \"read\" permission on \"Dup Perm Model\" to \"grantee\"",
+ "match": {
+ "location": "tests/api/model-permission.steps.ts:35"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 16621083
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "When ",
+ "line": 75,
+ "name": "\"owner\" grants \"read\" permission on \"Dup Perm Model\" to \"grantee\"",
+ "match": {
+ "location": "tests/api/model-permission.steps.ts:13"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 5489499
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Then ",
+ "line": 76,
+ "name": "the response status should be 409",
+ "match": {
+ "location": "tests/shared/common.steps.ts:13"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 27208
+ }
+ },
+ {
+ "keyword": "After",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 44999
+ },
+ "embeddings": [
+ {
+ "data": "U3RhdHVzOiBQQVNTRUQuIER1cmF0aW9uOjBz",
+ "mime_type": "text/plain"
+ }
+ ]
+ }
+ ],
+ "tags": [],
+ "type": "scenario"
+ },
+ {
+ "description": "",
+ "id": "model-permissions;grant-admin-permission-allows-managing-permissions",
+ "keyword": "Scenario",
+ "line": 78,
+ "name": "Grant admin permission allows managing permissions",
+ "steps": [
+ {
+ "keyword": "Before",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 81537291
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Given ",
+ "line": 79,
+ "name": "an authenticated user \"owner\"",
+ "match": {
+ "location": "tests/api/user.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 145796500
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 80,
+ "name": "a private model \"Delegated Model\" created by \"owner\"",
+ "match": {
+ "location": "tests/api/model.steps.ts:145"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 42739375
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 81,
+ "name": "an authenticated user \"delegate\"",
+ "match": {
+ "location": "tests/api/user.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 136843333
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 82,
+ "name": "\"owner\" has granted \"admin\" permission on \"Delegated Model\" to \"delegate\"",
+ "match": {
+ "location": "tests/api/model-permission.steps.ts:35"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 16483958
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 83,
+ "name": "an authenticated user \"third\"",
+ "match": {
+ "location": "tests/api/user.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 152773958
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "When ",
+ "line": 84,
+ "name": "\"delegate\" grants \"read\" permission on \"Delegated Model\" to \"third\"",
+ "match": {
+ "location": "tests/api/model-permission.steps.ts:13"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 10119249
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Then ",
+ "line": 85,
+ "name": "the response status should be 201",
+ "match": {
+ "location": "tests/shared/common.steps.ts:13"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 39625
+ }
+ },
+ {
+ "keyword": "After",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 38874
+ },
+ "embeddings": [
+ {
+ "data": "U3RhdHVzOiBQQVNTRUQuIER1cmF0aW9uOjBz",
+ "mime_type": "text/plain"
+ }
+ ]
+ }
+ ],
+ "tags": [],
+ "type": "scenario"
+ }
+ ],
+ "id": "model-permissions",
+ "line": 1,
+ "keyword": "Feature",
+ "name": "Model Permissions",
+ "tags": [],
+ "uri": "tests/api/model-permission.feature"
+ },
+ {
+ "description": " As a model author\n I want to tag model versions\n So that models are discoverable by topic",
+ "elements": [
+ {
+ "description": "",
+ "id": "model-version-tags;add-a-tag-to-a-model's-current-version",
+ "keyword": "Scenario",
+ "line": 6,
+ "name": "Add a tag to a model's current version",
+ "steps": [
+ {
+ "keyword": "Before",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 82629791
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Given ",
+ "line": 7,
+ "name": "an authenticated user",
+ "match": {
+ "location": "tests/api/user.steps.ts:11"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 143878499
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 8,
+ "name": "a public model \"Tagged Model\" created by the current user",
+ "match": {
+ "location": "tests/api/model.steps.ts:118"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 48228957
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 9,
+ "name": "a version \"v1\" for \"Tagged Model\" with title \"Initial\"",
+ "match": {
+ "location": "tests/api/model-version.steps.ts:48"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 16341917
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "When ",
+ "line": 10,
+ "name": "I add tag \"ecology\" to model \"Tagged Model\"",
+ "match": {
+ "location": "tests/api/model-version-tag.steps.ts:18"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 19118957
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Then ",
+ "line": 11,
+ "name": "the response status should be 201",
+ "match": {
+ "location": "tests/shared/common.steps.ts:13"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 47790
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 12,
+ "name": "the response body should have property \"tagId\"",
+ "match": {
+ "location": "tests/shared/common.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 37541
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 13,
+ "name": "the response body property \"tagName\" should equal \"ecology\"",
+ "match": {
+ "location": "tests/shared/common.steps.ts:25"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 26999
+ }
+ },
+ {
+ "keyword": "After",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 37749
+ },
+ "embeddings": [
+ {
+ "data": "U3RhdHVzOiBQQVNTRUQuIER1cmF0aW9uOjBz",
+ "mime_type": "text/plain"
+ }
+ ]
+ }
+ ],
+ "tags": [],
+ "type": "scenario"
+ },
+ {
+ "description": "",
+ "id": "model-version-tags;add-multiple-tags-to-a-model",
+ "keyword": "Scenario",
+ "line": 15,
+ "name": "Add multiple tags to a model",
+ "steps": [
+ {
+ "keyword": "Before",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 77666582
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Given ",
+ "line": 16,
+ "name": "an authenticated user",
+ "match": {
+ "location": "tests/api/user.steps.ts:11"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 145867207
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 17,
+ "name": "a public model \"Multi Tag Model\" created by the current user",
+ "match": {
+ "location": "tests/api/model.steps.ts:118"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 42351749
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 18,
+ "name": "a version \"v1\" for \"Multi Tag Model\" with title \"Initial\"",
+ "match": {
+ "location": "tests/api/model-version.steps.ts:48"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 15576582
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "When ",
+ "line": 19,
+ "name": "I add tag \"simulation\" to model \"Multi Tag Model\"",
+ "match": {
+ "location": "tests/api/model-version-tag.steps.ts:18"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 11163375
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Then ",
+ "line": 20,
+ "name": "the response status should be 201",
+ "match": {
+ "location": "tests/shared/common.steps.ts:13"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 29957
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "When ",
+ "line": 21,
+ "name": "I add tag \"netlogo\" to model \"Multi Tag Model\"",
+ "match": {
+ "location": "tests/api/model-version-tag.steps.ts:18"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 8037291
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Then ",
+ "line": 22,
+ "name": "the response status should be 201",
+ "match": {
+ "location": "tests/shared/common.steps.ts:13"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 47417
+ }
+ },
+ {
+ "keyword": "After",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 48374
+ },
+ "embeddings": [
+ {
+ "data": "U3RhdHVzOiBQQVNTRUQuIER1cmF0aW9uOjBz",
+ "mime_type": "text/plain"
+ }
+ ]
+ }
+ ],
+ "tags": [],
+ "type": "scenario"
+ },
+ {
+ "description": "",
+ "id": "model-version-tags;cannot-add-the-same-tag-twice",
+ "keyword": "Scenario",
+ "line": 24,
+ "name": "Cannot add the same tag twice",
+ "steps": [
+ {
+ "keyword": "Before",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 73804292
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Given ",
+ "line": 25,
+ "name": "an authenticated user",
+ "match": {
+ "location": "tests/api/user.steps.ts:11"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 141975042
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 26,
+ "name": "a public model \"Dup Tag Model\" created by the current user",
+ "match": {
+ "location": "tests/api/model.steps.ts:118"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 50037292
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 27,
+ "name": "a version \"v1\" for \"Dup Tag Model\" with title \"Initial\"",
+ "match": {
+ "location": "tests/api/model-version.steps.ts:48"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 17339082
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 28,
+ "name": "tag \"duplicate\" has been added to model \"Dup Tag Model\"",
+ "match": {
+ "location": "tests/api/model-version-tag.steps.ts:36"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 10661917
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "When ",
+ "line": 29,
+ "name": "I add tag \"duplicate\" to model \"Dup Tag Model\"",
+ "match": {
+ "location": "tests/api/model-version-tag.steps.ts:18"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 5298041
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Then ",
+ "line": 30,
+ "name": "the response status should be 409",
+ "match": {
+ "location": "tests/shared/common.steps.ts:13"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 31375
+ }
+ },
+ {
+ "keyword": "After",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 49125
+ },
+ "embeddings": [
+ {
+ "data": "U3RhdHVzOiBQQVNTRUQuIER1cmF0aW9uOjBz",
+ "mime_type": "text/plain"
+ }
+ ]
+ }
+ ],
+ "tags": [],
+ "type": "scenario"
+ },
+ {
+ "description": "",
+ "id": "model-version-tags;remove-a-tag-from-a-model",
+ "keyword": "Scenario",
+ "line": 32,
+ "name": "Remove a tag from a model",
+ "steps": [
+ {
+ "keyword": "Before",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 72372791
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Given ",
+ "line": 33,
+ "name": "an authenticated user",
+ "match": {
+ "location": "tests/api/user.steps.ts:11"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 143746875
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 34,
+ "name": "a public model \"Untagged Model\" created by the current user",
+ "match": {
+ "location": "tests/api/model.steps.ts:118"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 41462625
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 35,
+ "name": "a version \"v1\" for \"Untagged Model\" with title \"Initial\"",
+ "match": {
+ "location": "tests/api/model-version.steps.ts:48"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 15738166
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 36,
+ "name": "tag \"removable\" has been added to model \"Untagged Model\"",
+ "match": {
+ "location": "tests/api/model-version-tag.steps.ts:36"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 12284999
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "When ",
+ "line": 37,
+ "name": "I remove tag \"removable\" from model \"Untagged Model\"",
+ "match": {
+ "location": "tests/api/model-version-tag.steps.ts:55"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 7472916
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Then ",
+ "line": 38,
+ "name": "the response status should be 204",
+ "match": {
+ "location": "tests/shared/common.steps.ts:13"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 36917
+ }
+ },
+ {
+ "keyword": "After",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 46834
+ },
+ "embeddings": [
+ {
+ "data": "U3RhdHVzOiBQQVNTRUQuIER1cmF0aW9uOjBz",
+ "mime_type": "text/plain"
+ }
+ ]
+ }
+ ],
+ "tags": [],
+ "type": "scenario"
+ },
+ {
+ "description": "",
+ "id": "model-version-tags;list-tags-for-a-specific-version",
+ "keyword": "Scenario",
+ "line": 40,
+ "name": "List tags for a specific version",
+ "steps": [
+ {
+ "keyword": "Before",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 72806500
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Given ",
+ "line": 41,
+ "name": "an authenticated user",
+ "match": {
+ "location": "tests/api/user.steps.ts:11"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 145973707
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 42,
+ "name": "a public model \"Version Tags\" created by the current user",
+ "match": {
+ "location": "tests/api/model.steps.ts:118"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 42041916
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 43,
+ "name": "a version \"v1\" for \"Version Tags\" with title \"First\"",
+ "match": {
+ "location": "tests/api/model-version.steps.ts:48"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 17198832
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 44,
+ "name": "tag \"biology\" has been added to model \"Version Tags\"",
+ "match": {
+ "location": "tests/api/model-version-tag.steps.ts:36"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 10739540
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "When ",
+ "line": 45,
+ "name": "I list tags for version 1 of model \"Version Tags\"",
+ "match": {
+ "location": "tests/api/model-version-tag.steps.ts:69"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 4772583
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Then ",
+ "line": 46,
+ "name": "the response status should be 200",
+ "match": {
+ "location": "tests/shared/common.steps.ts:13"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 36625
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 47,
+ "name": "the response body should be an array",
+ "match": {
+ "location": "tests/shared/common.steps.ts:33"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 24708
+ }
+ },
+ {
+ "keyword": "After",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 51791
+ },
+ "embeddings": [
+ {
+ "data": "U3RhdHVzOiBQQVNTRUQuIER1cmF0aW9uOjBz",
+ "mime_type": "text/plain"
+ }
+ ]
+ }
+ ],
+ "tags": [],
+ "type": "scenario"
+ },
+ {
+ "description": "",
+ "id": "model-version-tags;tagging-requires-write-permission",
+ "keyword": "Scenario",
+ "line": 49,
+ "name": "Tagging requires write permission",
+ "steps": [
+ {
+ "keyword": "Before",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 72505957
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Given ",
+ "line": 50,
+ "name": "an authenticated user \"owner\"",
+ "match": {
+ "location": "tests/api/user.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 140835541
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 51,
+ "name": "a private model \"Protected Model\" created by \"owner\"",
+ "match": {
+ "location": "tests/api/model.steps.ts:145"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 38536665
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 52,
+ "name": "a version \"v1\" for \"Protected Model\" with title \"Secret\"",
+ "match": {
+ "location": "tests/api/model-version.steps.ts:48"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 15863249
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 53,
+ "name": "an authenticated user \"stranger\"",
+ "match": {
+ "location": "tests/api/user.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 139580416
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "When ",
+ "line": 54,
+ "name": "\"stranger\" adds tag \"hacked\" to model \"Protected Model\"",
+ "match": {
+ "location": "tests/api/model-version-tag.steps.ts:82"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 6454458
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Then ",
+ "line": 55,
+ "name": "the response status should be 403",
+ "match": {
+ "location": "tests/shared/common.steps.ts:13"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 31166
+ }
+ },
+ {
+ "keyword": "After",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 40750
+ },
+ "embeddings": [
+ {
+ "data": "U3RhdHVzOiBQQVNTRUQuIER1cmF0aW9uOjBz",
+ "mime_type": "text/plain"
+ }
+ ]
+ }
+ ],
+ "tags": [],
+ "type": "scenario"
+ },
+ {
+ "description": "",
+ "id": "model-version-tags;adding-a-tag-creates-the-tag-if-it-does-not-exist",
+ "keyword": "Scenario",
+ "line": 57,
+ "name": "Adding a tag creates the tag if it does not exist",
+ "steps": [
+ {
+ "keyword": "Before",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 82352958
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Given ",
+ "line": 58,
+ "name": "an authenticated user",
+ "match": {
+ "location": "tests/api/user.steps.ts:11"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 144001625
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 59,
+ "name": "a public model \"New Tag Model\" created by the current user",
+ "match": {
+ "location": "tests/api/model.steps.ts:118"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 57736625
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 60,
+ "name": "a version \"v1\" for \"New Tag Model\" with title \"Initial\"",
+ "match": {
+ "location": "tests/api/model-version.steps.ts:48"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 14638249
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "When ",
+ "line": 61,
+ "name": "I add tag \"brand-new-tag\" to model \"New Tag Model\"",
+ "match": {
+ "location": "tests/api/model-version-tag.steps.ts:18"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 10336791
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Then ",
+ "line": 62,
+ "name": "the response status should be 201",
+ "match": {
+ "location": "tests/shared/common.steps.ts:13"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 36666
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "When ",
+ "line": 63,
+ "name": "I send a GET request to \"/api/v1/tags?q=brand-new\"",
+ "match": {
+ "location": "tests/api/health.steps.ts:4"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 4534665
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Then ",
+ "line": 64,
+ "name": "the response status should be 200",
+ "match": {
+ "location": "tests/shared/common.steps.ts:13"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 25749
+ }
+ },
+ {
+ "keyword": "After",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 40249
+ },
+ "embeddings": [
+ {
+ "data": "U3RhdHVzOiBQQVNTRUQuIER1cmF0aW9uOjBz",
+ "mime_type": "text/plain"
+ }
+ ]
+ }
+ ],
+ "tags": [],
+ "type": "scenario"
+ }
+ ],
+ "id": "model-version-tags",
+ "line": 1,
+ "keyword": "Feature",
+ "name": "Model Version Tags",
+ "tags": [],
+ "uri": "tests/api/model-version-tag.feature"
+ },
+ {
+ "description": " As a model author\n I want to manage model versions\n So that I can track changes to my models over time",
+ "elements": [
+ {
+ "description": "",
+ "id": "model-versions;create-a-version-for-a-model",
+ "keyword": "Scenario",
+ "line": 6,
+ "name": "Create a version for a model",
+ "steps": [
+ {
+ "keyword": "Before",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 79295458
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Given ",
+ "line": 7,
+ "name": "an authenticated user",
+ "match": {
+ "location": "tests/api/user.steps.ts:11"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 154138666
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 8,
+ "name": "a public model \"My Model\" created by the current user",
+ "match": {
+ "location": "tests/api/model.steps.ts:118"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 47326708
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "When ",
+ "line": 9,
+ "name": "I create a version for \"My Model\" with title \"Initial Version\"",
+ "match": {
+ "location": "tests/api/model-version.steps.ts:68"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 16670540
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Then ",
+ "line": 10,
+ "name": "the response status should be 201",
+ "match": {
+ "location": "tests/shared/common.steps.ts:13"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 49958
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 11,
+ "name": "the response body should have property \"versionNumber\"",
+ "match": {
+ "location": "tests/shared/common.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 74791
+ }
+ },
+ {
+ "keyword": "After",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 42124
+ },
+ "embeddings": [
+ {
+ "data": "U3RhdHVzOiBQQVNTRUQuIER1cmF0aW9uOjBz",
+ "mime_type": "text/plain"
+ }
+ ]
+ }
+ ],
+ "tags": [],
+ "type": "scenario"
+ },
+ {
+ "description": "",
+ "id": "model-versions;list-versions-of-a-model-after-creating-an-additional-version",
+ "keyword": "Scenario",
+ "line": 21,
+ "name": "List versions of a model after creating an additional version",
+ "steps": [
+ {
+ "keyword": "Before",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 73974791
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Given ",
+ "line": 22,
+ "name": "an authenticated user",
+ "match": {
+ "location": "tests/api/user.steps.ts:11"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 146266458
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 23,
+ "name": "a public model \"Versioned Model\" created by the current user",
+ "match": {
+ "location": "tests/api/model.steps.ts:118"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 42417916
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 24,
+ "name": "a version \"v2\" for \"Versioned Model\" with title \"Release 2\"",
+ "match": {
+ "location": "tests/api/model-version.steps.ts:48"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 15534332
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "When ",
+ "line": 25,
+ "name": "I list versions of the model \"Versioned Model\"",
+ "match": {
+ "location": "tests/api/model-version.steps.ts:98"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 9189667
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Then ",
+ "line": 26,
+ "name": "the response status should be 200",
+ "match": {
+ "location": "tests/shared/common.steps.ts:13"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 35124
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 27,
+ "name": "the response body should have property \"data\" as an array",
+ "match": {
+ "location": "tests/shared/common.steps.ts:41"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 42874
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 28,
+ "name": "the response body property \"data\" should have length 2",
+ "match": {
+ "location": "tests/shared/common.steps.ts:49"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 27958
+ }
+ },
+ {
+ "keyword": "After",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 30000
+ },
+ "embeddings": [
+ {
+ "data": "U3RhdHVzOiBQQVNTRUQuIER1cmF0aW9uOjBz",
+ "mime_type": "text/plain"
+ }
+ ]
+ }
+ ],
+ "tags": [],
+ "type": "scenario"
+ },
+ {
+ "description": "",
+ "id": "model-versions;get-a-specific-version-by-number",
+ "keyword": "Scenario",
+ "line": 40,
+ "name": "Get a specific version by number",
+ "steps": [
+ {
+ "keyword": "Before",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 75649624
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Given ",
+ "line": 41,
+ "name": "an authenticated user",
+ "match": {
+ "location": "tests/api/user.steps.ts:11"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 140500791
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 42,
+ "name": "a public model \"Specific Model\" created by the current user",
+ "match": {
+ "location": "tests/api/model.steps.ts:118"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 40751458
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 43,
+ "name": "a version \"v2\" for \"Specific Model\" with title \"The Version\"",
+ "match": {
+ "location": "tests/api/model-version.steps.ts:48"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 14947959
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "When ",
+ "line": 44,
+ "name": "I get version 2 of the model \"Specific Model\"",
+ "match": {
+ "location": "tests/api/model-version.steps.ts:108"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 8688500
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Then ",
+ "line": 45,
+ "name": "the response status should be 200",
+ "match": {
+ "location": "tests/shared/common.steps.ts:13"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 46292
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 46,
+ "name": "the response body property \"title\" should equal \"The Version\"",
+ "match": {
+ "location": "tests/shared/common.steps.ts:25"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 41042
+ }
+ },
+ {
+ "keyword": "After",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 36667
+ },
+ "embeddings": [
+ {
+ "data": "U3RhdHVzOiBQQVNTRUQuIER1cmF0aW9uOjBz",
+ "mime_type": "text/plain"
+ }
+ ]
+ }
+ ],
+ "tags": [],
+ "type": "scenario"
+ },
+ {
+ "description": "",
+ "id": "model-versions;update-the-current-version-title",
+ "keyword": "Scenario",
+ "line": 48,
+ "name": "Update the current version title",
+ "steps": [
+ {
+ "keyword": "Before",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 76374874
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Given ",
+ "line": 49,
+ "name": "an authenticated user",
+ "match": {
+ "location": "tests/api/user.steps.ts:11"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 141505582
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 50,
+ "name": "a public model \"Editable Model\" created by the current user",
+ "match": {
+ "location": "tests/api/model.steps.ts:118"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 42494584
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 51,
+ "name": "a version \"v1\" for \"Editable Model\" with title \"Old Title\"",
+ "match": {
+ "location": "tests/api/model-version.steps.ts:48"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 13658166
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "When ",
+ "line": 52,
+ "name": "I update the current version of \"Editable Model\" with title \"New Title\"",
+ "match": {
+ "location": "tests/api/model-version.steps.ts:121"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 9547207
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Then ",
+ "line": 53,
+ "name": "the response status should be 204",
+ "match": {
+ "location": "tests/shared/common.steps.ts:13"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 34166
+ }
+ },
+ {
+ "keyword": "After",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 45083
+ },
+ "embeddings": [
+ {
+ "data": "U3RhdHVzOiBQQVNTRUQuIER1cmF0aW9uOjBz",
+ "mime_type": "text/plain"
+ }
+ ]
+ }
+ ],
+ "tags": [],
+ "type": "scenario"
+ },
+ {
+ "description": "",
+ "id": "model-versions;update-the-current-version-description",
+ "keyword": "Scenario",
+ "line": 55,
+ "name": "Update the current version description",
+ "steps": [
+ {
+ "keyword": "Before",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 89853416
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Given ",
+ "line": 56,
+ "name": "an authenticated user",
+ "match": {
+ "location": "tests/api/user.steps.ts:11"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 158567541
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 57,
+ "name": "a public model \"Described Model\" created by the current user",
+ "match": {
+ "location": "tests/api/model.steps.ts:118"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 48859457
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 58,
+ "name": "a version \"v1\" for \"Described Model\" with title \"Some Title\"",
+ "match": {
+ "location": "tests/api/model-version.steps.ts:48"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 14415750
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "When ",
+ "line": 59,
+ "name": "I update the current version of \"Described Model\" with description \"A detailed description\"",
+ "match": {
+ "location": "tests/api/model-version.steps.ts:135"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 7158167
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Then ",
+ "line": 60,
+ "name": "the response status should be 204",
+ "match": {
+ "location": "tests/shared/common.steps.ts:13"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 29500
+ }
+ },
+ {
+ "keyword": "After",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 43042
+ },
+ "embeddings": [
+ {
+ "data": "U3RhdHVzOiBQQVNTRUQuIER1cmF0aW9uOjBz",
+ "mime_type": "text/plain"
+ }
+ ]
+ }
+ ],
+ "tags": [],
+ "type": "scenario"
+ },
+ {
+ "description": "",
+ "id": "model-versions;creating-a-version-requires-write-permission",
+ "keyword": "Scenario",
+ "line": 62,
+ "name": "Creating a version requires write permission",
+ "steps": [
+ {
+ "keyword": "Before",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 78445333
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Given ",
+ "line": 63,
+ "name": "an authenticated user \"owner\"",
+ "match": {
+ "location": "tests/api/user.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 146469417
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 64,
+ "name": "a private model \"Locked Model\" created by \"owner\"",
+ "match": {
+ "location": "tests/api/model.steps.ts:145"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 43200375
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 65,
+ "name": "an authenticated user \"stranger\"",
+ "match": {
+ "location": "tests/api/user.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 136447541
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "When ",
+ "line": 66,
+ "name": "\"stranger\" creates a version for \"Locked Model\" with title \"Nope\"",
+ "match": {
+ "location": "tests/api/model-version.steps.ts:83"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 5121125
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Then ",
+ "line": 67,
+ "name": "the response status should be 403",
+ "match": {
+ "location": "tests/shared/common.steps.ts:13"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 50458
+ }
+ },
+ {
+ "keyword": "After",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 50415
+ },
+ "embeddings": [
+ {
+ "data": "U3RhdHVzOiBQQVNTRUQuIER1cmF0aW9uOjBz",
+ "mime_type": "text/plain"
+ }
+ ]
+ }
+ ],
+ "tags": [],
+ "type": "scenario"
+ },
+ {
+ "description": "",
+ "id": "model-versions;listing-versions-requires-read-access",
+ "keyword": "Scenario",
+ "line": 69,
+ "name": "Listing versions requires read access",
+ "steps": [
+ {
+ "keyword": "Before",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 69427541
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Given ",
+ "line": 70,
+ "name": "an authenticated user \"owner\"",
+ "match": {
+ "location": "tests/api/user.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 143669167
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 71,
+ "name": "a private model \"Private Model\" created by \"owner\"",
+ "match": {
+ "location": "tests/api/model.steps.ts:145"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 40538333
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 72,
+ "name": "an authenticated user \"stranger\"",
+ "match": {
+ "location": "tests/api/user.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 134459667
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "When ",
+ "line": 73,
+ "name": "\"stranger\" lists versions of the model \"Private Model\"",
+ "match": {
+ "location": "tests/api/model-version.steps.ts:149"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 9189166
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Then ",
+ "line": 74,
+ "name": "the response status should be 403",
+ "match": {
+ "location": "tests/shared/common.steps.ts:13"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 38415
+ }
+ },
+ {
+ "keyword": "After",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 50917
+ },
+ "embeddings": [
+ {
+ "data": "U3RhdHVzOiBQQVNTRUQuIER1cmF0aW9uOjBz",
+ "mime_type": "text/plain"
+ }
+ ]
+ }
+ ],
+ "tags": [],
+ "type": "scenario"
+ },
+ {
+ "description": "",
+ "id": "model-versions;updating-a-version-requires-write-access",
+ "keyword": "Scenario",
+ "line": 76,
+ "name": "Updating a version requires write access",
+ "steps": [
+ {
+ "keyword": "Before",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 98361290
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Given ",
+ "line": 77,
+ "name": "an authenticated user \"owner\"",
+ "match": {
+ "location": "tests/api/user.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 147338625
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 78,
+ "name": "a public model \"Guarded Model\" created by \"owner\"",
+ "match": {
+ "location": "tests/api/model.steps.ts:127"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 43615207
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 79,
+ "name": "a version \"v1\" for \"Guarded Model\" with title \"Original\"",
+ "match": {
+ "location": "tests/api/model-version.steps.ts:48"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 15554541
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 80,
+ "name": "an authenticated user \"stranger\"",
+ "match": {
+ "location": "tests/api/user.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 138096292
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "When ",
+ "line": 81,
+ "name": "\"stranger\" updates the current version of \"Guarded Model\" with title \"Hacked\"",
+ "match": {
+ "location": "tests/api/model-version.steps.ts:162"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 5144917
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Then ",
+ "line": 82,
+ "name": "the response status should be 403",
+ "match": {
+ "location": "tests/shared/common.steps.ts:13"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 28125
+ }
+ },
+ {
+ "keyword": "After",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 28624
+ },
+ "embeddings": [
+ {
+ "data": "U3RhdHVzOiBQQVNTRUQuIER1cmF0aW9uOjBz",
+ "mime_type": "text/plain"
+ }
+ ]
+ }
+ ],
+ "tags": [],
+ "type": "scenario"
+ },
+ {
+ "description": "",
+ "id": "model-versions;a-freshly-published-model-lists-its-initial-version",
+ "keyword": "Scenario",
+ "line": 84,
+ "name": "A freshly-published model lists its initial version",
+ "steps": [
+ {
+ "keyword": "Before",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 88595750
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Given ",
+ "line": 85,
+ "name": "an authenticated user",
+ "match": {
+ "location": "tests/api/user.steps.ts:11"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 148864042
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 86,
+ "name": "a public model \"Initial Model\" created by the current user",
+ "match": {
+ "location": "tests/api/model.steps.ts:118"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 53047374
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "When ",
+ "line": 87,
+ "name": "I list versions of the model \"Initial Model\"",
+ "match": {
+ "location": "tests/api/model-version.steps.ts:98"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 5727457
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Then ",
+ "line": 88,
+ "name": "the response status should be 200",
+ "match": {
+ "location": "tests/shared/common.steps.ts:13"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 29957
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 89,
+ "name": "the response body should have property \"data\" as an array",
+ "match": {
+ "location": "tests/shared/common.steps.ts:41"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 31375
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 90,
+ "name": "the response body property \"data\" should have length 1",
+ "match": {
+ "location": "tests/shared/common.steps.ts:49"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 20791
+ }
+ },
+ {
+ "keyword": "After",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 32208
+ },
+ "embeddings": [
+ {
+ "data": "U3RhdHVzOiBQQVNTRUQuIER1cmF0aW9uOjBz",
+ "mime_type": "text/plain"
+ }
+ ]
+ }
+ ],
+ "tags": [],
+ "type": "scenario"
+ }
+ ],
+ "id": "model-versions",
+ "line": 1,
+ "keyword": "Feature",
+ "name": "Model Versions",
+ "tags": [],
+ "uri": "tests/api/model-version.feature"
+ },
+ {
+ "description": " As a user\n I want to manage models\n So that I can create, view, update, and delete models",
+ "elements": [
+ {
+ "description": "",
+ "id": "model-management;create-a-model",
+ "keyword": "Scenario",
+ "line": 6,
+ "name": "Create a model",
+ "steps": [
+ {
+ "keyword": "Before",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 82599166
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Given ",
+ "line": 7,
+ "name": "an authenticated user",
+ "match": {
+ "location": "tests/api/user.steps.ts:11"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 143414415
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "When ",
+ "line": 8,
+ "name": "I create a model with title \"Wolf Sheep Predation\"",
+ "match": {
+ "location": "tests/api/model.steps.ts:107"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 46546374
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Then ",
+ "line": 9,
+ "name": "the response status should be 201",
+ "match": {
+ "location": "tests/shared/common.steps.ts:13"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 37165
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 10,
+ "name": "the response body should have property \"id\"",
+ "match": {
+ "location": "tests/shared/common.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 77666
+ }
+ },
+ {
+ "keyword": "After",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 39667
+ },
+ "embeddings": [
+ {
+ "data": "U3RhdHVzOiBQQVNTRUQuIER1cmF0aW9uOjBz",
+ "mime_type": "text/plain"
+ }
+ ]
+ }
+ ],
+ "tags": [],
+ "type": "scenario"
+ },
+ {
+ "description": "",
+ "id": "model-management;create-a-model-requires-authentication",
+ "keyword": "Scenario",
+ "line": 12,
+ "name": "Create a model requires authentication",
+ "steps": [
+ {
+ "keyword": "Before",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 87883999
+ }
+ },
+ {
+ "arguments": [
+ {
+ "content": "{}",
+ "line": 14
+ }
+ ],
+ "keyword": "When ",
+ "line": 13,
+ "name": "I send a POST request to \"/api/v1/model-drafts\" with JSON body:",
+ "match": {
+ "location": "tests/api/model.steps.ts:91"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 863165
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Then ",
+ "line": 17,
+ "name": "the response status should be 401",
+ "match": {
+ "location": "tests/shared/common.steps.ts:13"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 22042
+ }
+ },
+ {
+ "keyword": "After",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 23084
+ },
+ "embeddings": [
+ {
+ "data": "U3RhdHVzOiBQQVNTRUQuIER1cmF0aW9uOjBz",
+ "mime_type": "text/plain"
+ }
+ ]
+ }
+ ],
+ "tags": [],
+ "type": "scenario"
+ },
+ {
+ "description": "",
+ "id": "model-management;get-a-public-model",
+ "keyword": "Scenario",
+ "line": 19,
+ "name": "Get a public model",
+ "steps": [
+ {
+ "keyword": "Before",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 84840542
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Given ",
+ "line": 20,
+ "name": "an authenticated user",
+ "match": {
+ "location": "tests/api/user.steps.ts:11"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 164540124
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 21,
+ "name": "a public model \"Ants\" created by the current user",
+ "match": {
+ "location": "tests/api/model.steps.ts:118"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 71695457
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "When ",
+ "line": 22,
+ "name": "I get the model \"Ants\"",
+ "match": {
+ "location": "tests/api/model.steps.ts:175"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 8447458
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Then ",
+ "line": 23,
+ "name": "the response status should be 200",
+ "match": {
+ "location": "tests/shared/common.steps.ts:13"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 41457
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 24,
+ "name": "the response body should have property \"id\"",
+ "match": {
+ "location": "tests/shared/common.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 27917
+ }
+ },
+ {
+ "keyword": "After",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 38334
+ },
+ "embeddings": [
+ {
+ "data": "U3RhdHVzOiBQQVNTRUQuIER1cmF0aW9uOjBz",
+ "mime_type": "text/plain"
+ }
+ ]
+ }
+ ],
+ "tags": [],
+ "type": "scenario"
+ },
+ {
+ "description": "",
+ "id": "model-management;list-public-models",
+ "keyword": "Scenario",
+ "line": 26,
+ "name": "List public models",
+ "steps": [
+ {
+ "keyword": "Before",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 74713041
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Given ",
+ "line": 27,
+ "name": "an authenticated user",
+ "match": {
+ "location": "tests/api/user.steps.ts:11"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 147329125
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 28,
+ "name": "a public model \"Model A\" created by the current user",
+ "match": {
+ "location": "tests/api/model.steps.ts:118"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 71176041
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 29,
+ "name": "a public model \"Model B\" created by the current user",
+ "match": {
+ "location": "tests/api/model.steps.ts:118"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 47260249
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "When ",
+ "line": 30,
+ "name": "I send a GET request to \"/api/v1/models\"",
+ "match": {
+ "location": "tests/api/health.steps.ts:4"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 9362166
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Then ",
+ "line": 31,
+ "name": "the response status should be 200",
+ "match": {
+ "location": "tests/shared/common.steps.ts:13"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 39790
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 32,
+ "name": "the response body should have property \"data\" as an array",
+ "match": {
+ "location": "tests/shared/common.steps.ts:41"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 24917
+ }
+ },
+ {
+ "keyword": "After",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 24916
+ },
+ "embeddings": [
+ {
+ "data": "U3RhdHVzOiBQQVNTRUQuIER1cmF0aW9uOjBz",
+ "mime_type": "text/plain"
+ }
+ ]
+ }
+ ],
+ "tags": [],
+ "type": "scenario"
+ },
+ {
+ "description": "",
+ "id": "model-management;update-a-model's-visibility",
+ "keyword": "Scenario",
+ "line": 34,
+ "name": "Update a model's visibility",
+ "steps": [
+ {
+ "keyword": "Before",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 74310459
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Given ",
+ "line": 35,
+ "name": "an authenticated user",
+ "match": {
+ "location": "tests/api/user.steps.ts:11"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 141833208
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 36,
+ "name": "a public model \"My Model\" created by the current user",
+ "match": {
+ "location": "tests/api/model.steps.ts:118"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 44525624
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "When ",
+ "line": 37,
+ "name": "I update the model \"My Model\" with visibility \"private\"",
+ "match": {
+ "location": "tests/api/model.steps.ts:198"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 7488915
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Then ",
+ "line": 38,
+ "name": "the response status should be 204",
+ "match": {
+ "location": "tests/shared/common.steps.ts:13"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 29999
+ }
+ },
+ {
+ "keyword": "After",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 37625
+ },
+ "embeddings": [
+ {
+ "data": "U3RhdHVzOiBQQVNTRUQuIER1cmF0aW9uOjBz",
+ "mime_type": "text/plain"
+ }
+ ]
+ }
+ ],
+ "tags": [],
+ "type": "scenario"
+ },
+ {
+ "description": "",
+ "id": "model-management;delete-a-model",
+ "keyword": "Scenario",
+ "line": 40,
+ "name": "Delete a model",
+ "steps": [
+ {
+ "keyword": "Before",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 77999500
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Given ",
+ "line": 41,
+ "name": "an authenticated user",
+ "match": {
+ "location": "tests/api/user.steps.ts:11"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 167859250
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 42,
+ "name": "a public model \"To Delete\" created by the current user",
+ "match": {
+ "location": "tests/api/model.steps.ts:118"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 118330708
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "When ",
+ "line": 43,
+ "name": "I delete the model \"To Delete\"",
+ "match": {
+ "location": "tests/api/model.steps.ts:212"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 29019958
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Then ",
+ "line": 44,
+ "name": "the response status should be 204",
+ "match": {
+ "location": "tests/shared/common.steps.ts:13"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 39625
+ }
+ },
+ {
+ "keyword": "After",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 44541
+ },
+ "embeddings": [
+ {
+ "data": "U3RhdHVzOiBQQVNTRUQuIER1cmF0aW9uOjBz",
+ "mime_type": "text/plain"
+ }
+ ]
+ }
+ ],
+ "tags": [],
+ "type": "scenario"
+ },
+ {
+ "description": "",
+ "id": "model-management;cannot-access-a-private-model-without-permission",
+ "keyword": "Scenario",
+ "line": 46,
+ "name": "Cannot access a private model without permission",
+ "steps": [
+ {
+ "keyword": "Before",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 355949083
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Given ",
+ "line": 47,
+ "name": "an authenticated user \"owner\"",
+ "match": {
+ "location": "tests/api/user.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 217289583
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 48,
+ "name": "a private model \"Secret Model\" created by \"owner\"",
+ "match": {
+ "location": "tests/api/model.steps.ts:145"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 129122708
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 49,
+ "name": "an authenticated user \"stranger\"",
+ "match": {
+ "location": "tests/api/user.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 192365958
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "When ",
+ "line": 50,
+ "name": "\"stranger\" gets the model \"Secret Model\"",
+ "match": {
+ "location": "tests/api/model.steps.ts:185"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 33899459
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Then ",
+ "line": 51,
+ "name": "the response status should be 403",
+ "match": {
+ "location": "tests/shared/common.steps.ts:13"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 89249
+ }
+ },
+ {
+ "keyword": "After",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 120582
+ },
+ "embeddings": [
+ {
+ "data": "U3RhdHVzOiBQQVNTRUQuIER1cmF0aW9uOjBz",
+ "mime_type": "text/plain"
+ }
+ ]
+ }
+ ],
+ "tags": [],
+ "type": "scenario"
+ },
+ {
+ "description": "",
+ "id": "model-management;search-models-by-keyword",
+ "keyword": "Scenario",
+ "line": 53,
+ "name": "Search models by keyword",
+ "steps": [
+ {
+ "keyword": "Before",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 314908457
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Given ",
+ "line": 54,
+ "name": "an authenticated user",
+ "match": {
+ "location": "tests/api/user.steps.ts:11"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 199878750
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 55,
+ "name": "a public model \"Climate Change Sim\" created by the current user",
+ "match": {
+ "location": "tests/api/model.steps.ts:118"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 132083958
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "When ",
+ "line": 56,
+ "name": "I send a GET request to \"/api/v1/models?keyword=Climate\"",
+ "match": {
+ "location": "tests/api/health.steps.ts:4"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 17765458
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Then ",
+ "line": 57,
+ "name": "the response status should be 200",
+ "match": {
+ "location": "tests/shared/common.steps.ts:13"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 35625
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 58,
+ "name": "the response body should have property \"data\" as an array",
+ "match": {
+ "location": "tests/shared/common.steps.ts:41"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 41499
+ }
+ },
+ {
+ "keyword": "After",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 41208
+ },
+ "embeddings": [
+ {
+ "data": "U3RhdHVzOiBQQVNTRUQuIER1cmF0aW9uOjBz",
+ "mime_type": "text/plain"
+ }
+ ]
+ }
+ ],
+ "tags": [],
+ "type": "scenario"
+ },
+ {
+ "description": "",
+ "id": "model-management;create-a-forked-model",
+ "keyword": "Scenario",
+ "line": 60,
+ "name": "Create a forked model",
+ "steps": [
+ {
+ "keyword": "Before",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 312042790
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Given ",
+ "line": 61,
+ "name": "an authenticated user",
+ "match": {
+ "location": "tests/api/user.steps.ts:11"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 193766874
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 62,
+ "name": "a public model \"Original\" created by the current user",
+ "match": {
+ "location": "tests/api/model.steps.ts:118"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 78847458
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "When ",
+ "line": 63,
+ "name": "I fork the model \"Original\" with title \"My Fork\"",
+ "match": {
+ "location": "tests/api/model.steps.ts:259"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 41937750
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Then ",
+ "line": 64,
+ "name": "the response status should be 201",
+ "match": {
+ "location": "tests/shared/common.steps.ts:13"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 36250
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 65,
+ "name": "the response body should have property \"id\"",
+ "match": {
+ "location": "tests/shared/common.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 62500
+ }
+ },
+ {
+ "keyword": "After",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 39624
+ },
+ "embeddings": [
+ {
+ "data": "U3RhdHVzOiBQQVNTRUQuIER1cmF0aW9uOjBz",
+ "mime_type": "text/plain"
+ }
+ ]
+ }
+ ],
+ "tags": [],
+ "type": "scenario"
+ },
+ {
+ "description": "",
+ "id": "model-management;unlisted-models-do-not-appear-in-search-results",
+ "keyword": "Scenario",
+ "line": 67,
+ "name": "Unlisted models do not appear in search results",
+ "steps": [
+ {
+ "keyword": "Before",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 74470083
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Given ",
+ "line": 68,
+ "name": "an authenticated user \"owner\"",
+ "match": {
+ "location": "tests/api/user.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 145600124
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 69,
+ "name": "an unlisted model \"Hidden Gem\" created by \"owner\"",
+ "match": {
+ "location": "tests/api/model.steps.ts:154"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 45366917
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "When ",
+ "line": 70,
+ "name": "\"owner\" sends a GET request to \"/api/v1/models\"",
+ "match": {
+ "location": "tests/api/model.steps.ts:163"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 3998541
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Then ",
+ "line": 71,
+ "name": "the response status should be 200",
+ "match": {
+ "location": "tests/shared/common.steps.ts:13"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 30207
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 72,
+ "name": "the response body property \"data\" should have length 0",
+ "match": {
+ "location": "tests/shared/common.steps.ts:49"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 61082
+ }
+ },
+ {
+ "keyword": "After",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 46041
+ },
+ "embeddings": [
+ {
+ "data": "U3RhdHVzOiBQQVNTRUQuIER1cmF0aW9uOjBz",
+ "mime_type": "text/plain"
+ }
+ ]
+ }
+ ],
+ "tags": [],
+ "type": "scenario"
+ },
+ {
+ "description": "",
+ "id": "model-management;private-models-do-not-appear-in-search-for-non-owners",
+ "keyword": "Scenario",
+ "line": 74,
+ "name": "Private models do not appear in search for non-owners",
+ "steps": [
+ {
+ "keyword": "Before",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 80738333
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Given ",
+ "line": 75,
+ "name": "an authenticated user \"owner\"",
+ "match": {
+ "location": "tests/api/user.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 144280833
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 76,
+ "name": "a private model \"Secret Stuff\" created by \"owner\"",
+ "match": {
+ "location": "tests/api/model.steps.ts:145"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 40932917
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 77,
+ "name": "an authenticated user \"stranger\"",
+ "match": {
+ "location": "tests/api/user.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 137140000
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "When ",
+ "line": 78,
+ "name": "\"stranger\" sends a GET request to \"/api/v1/models\"",
+ "match": {
+ "location": "tests/api/model.steps.ts:163"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 6118124
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Then ",
+ "line": 79,
+ "name": "the response status should be 200",
+ "match": {
+ "location": "tests/shared/common.steps.ts:13"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 32333
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 80,
+ "name": "the response body property \"data\" should have length 0",
+ "match": {
+ "location": "tests/shared/common.steps.ts:49"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 21958
+ }
+ },
+ {
+ "keyword": "After",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 40207
+ },
+ "embeddings": [
+ {
+ "data": "U3RhdHVzOiBQQVNTRUQuIER1cmF0aW9uOjBz",
+ "mime_type": "text/plain"
+ }
+ ]
+ }
+ ],
+ "tags": [],
+ "type": "scenario"
+ },
+ {
+ "description": "",
+ "id": "model-management;private-models-appear-in-search-for-the-owner-when-publiconly=false",
+ "keyword": "Scenario",
+ "line": 82,
+ "name": "Private models appear in search for the owner when publicOnly=false",
+ "steps": [
+ {
+ "keyword": "Before",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 82541541
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Given ",
+ "line": 83,
+ "name": "an authenticated user \"owner\"",
+ "match": {
+ "location": "tests/api/user.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 142345708
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 84,
+ "name": "a private model \"My Secret\" created by \"owner\"",
+ "match": {
+ "location": "tests/api/model.steps.ts:145"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 44098457
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "When ",
+ "line": 85,
+ "name": "\"owner\" sends a GET request to \"/api/v1/models?publicOnly=false\"",
+ "match": {
+ "location": "tests/api/model.steps.ts:163"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 8038166
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Then ",
+ "line": 86,
+ "name": "the response status should be 200",
+ "match": {
+ "location": "tests/shared/common.steps.ts:13"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 38250
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 87,
+ "name": "the response body property \"data\" should have length 1",
+ "match": {
+ "location": "tests/shared/common.steps.ts:49"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 31749
+ }
+ },
+ {
+ "keyword": "After",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 35250
+ },
+ "embeddings": [
+ {
+ "data": "U3RhdHVzOiBQQVNTRUQuIER1cmF0aW9uOjBz",
+ "mime_type": "text/plain"
+ }
+ ]
+ }
+ ],
+ "tags": [],
+ "type": "scenario"
+ },
+ {
+ "description": "",
+ "id": "model-management;permissions-action-map-for-anonymous-viewer-on-public-model",
+ "keyword": "Scenario",
+ "line": 89,
+ "name": "Permissions action map for anonymous viewer on public model",
+ "steps": [
+ {
+ "keyword": "Before",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 82350583
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Given ",
+ "line": 90,
+ "name": "an authenticated user \"owner\"",
+ "match": {
+ "location": "tests/api/user.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 136443040
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 91,
+ "name": "a public model \"Open Model\" created by \"owner\"",
+ "match": {
+ "location": "tests/api/model.steps.ts:127"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 41081500
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "When ",
+ "line": 92,
+ "name": "an anonymous viewer gets permissions for model \"Open Model\"",
+ "match": {
+ "location": "tests/api/model.steps.ts:235"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 1523416
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Then ",
+ "line": 93,
+ "name": "the response status should be 200",
+ "match": {
+ "location": "tests/shared/common.steps.ts:13"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 28458
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 94,
+ "name": "the response permissions action \"canView\" should be true",
+ "match": {
+ "location": "tests/api/model.steps.ts:246"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 40957
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 95,
+ "name": "the response permissions action \"canFork\" should be false",
+ "match": {
+ "location": "tests/api/model.steps.ts:246"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 11167
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 96,
+ "name": "the response permissions action \"canEdit\" should be false",
+ "match": {
+ "location": "tests/api/model.steps.ts:246"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 9708
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 97,
+ "name": "the response permissions action \"canDelete\" should be false",
+ "match": {
+ "location": "tests/api/model.steps.ts:246"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 9333
+ }
+ },
+ {
+ "keyword": "After",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 34874
+ },
+ "embeddings": [
+ {
+ "data": "U3RhdHVzOiBQQVNTRUQuIER1cmF0aW9uOjBz",
+ "mime_type": "text/plain"
+ }
+ ]
+ }
+ ],
+ "tags": [],
+ "type": "scenario"
+ },
+ {
+ "description": "",
+ "id": "model-management;permissions-action-map-for-the-owner",
+ "keyword": "Scenario",
+ "line": 99,
+ "name": "Permissions action map for the owner",
+ "steps": [
+ {
+ "keyword": "Before",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 80352041
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Given ",
+ "line": 100,
+ "name": "an authenticated user \"owner\"",
+ "match": {
+ "location": "tests/api/user.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 143981332
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 101,
+ "name": "a public model \"Owned Model\" created by \"owner\"",
+ "match": {
+ "location": "tests/api/model.steps.ts:127"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 44783125
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "When ",
+ "line": 102,
+ "name": "\"owner\" gets permissions for model \"Owned Model\"",
+ "match": {
+ "location": "tests/api/model.steps.ts:222"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 5219375
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Then ",
+ "line": 103,
+ "name": "the response status should be 200",
+ "match": {
+ "location": "tests/shared/common.steps.ts:13"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 40333
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 104,
+ "name": "the response permissions action \"canView\" should be true",
+ "match": {
+ "location": "tests/api/model.steps.ts:246"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 26375
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 105,
+ "name": "the response permissions action \"canEdit\" should be true",
+ "match": {
+ "location": "tests/api/model.steps.ts:246"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 13749
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 106,
+ "name": "the response permissions action \"canManageAuthors\" should be true",
+ "match": {
+ "location": "tests/api/model.steps.ts:246"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 10583
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 107,
+ "name": "the response permissions action \"canDelete\" should be true",
+ "match": {
+ "location": "tests/api/model.steps.ts:246"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 11500
+ }
+ },
+ {
+ "keyword": "After",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 37874
+ },
+ "embeddings": [
+ {
+ "data": "U3RhdHVzOiBQQVNTRUQuIER1cmF0aW9uOjBz",
+ "mime_type": "text/plain"
+ }
+ ]
+ }
+ ],
+ "tags": [],
+ "type": "scenario"
+ },
+ {
+ "description": "",
+ "id": "model-management;permissions-action-map-for-a-non-author-authenticated-viewer-on-public-model",
+ "keyword": "Scenario",
+ "line": 109,
+ "name": "Permissions action map for a non-author authenticated viewer on public model",
+ "steps": [
+ {
+ "keyword": "Before",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 75909290
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Given ",
+ "line": 110,
+ "name": "an authenticated user \"owner\"",
+ "match": {
+ "location": "tests/api/user.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 153993290
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 111,
+ "name": "a public model \"Visible Model\" created by \"owner\"",
+ "match": {
+ "location": "tests/api/model.steps.ts:127"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 45164041
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 112,
+ "name": "an authenticated user \"stranger\"",
+ "match": {
+ "location": "tests/api/user.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 139957083
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "When ",
+ "line": 113,
+ "name": "\"stranger\" gets permissions for model \"Visible Model\"",
+ "match": {
+ "location": "tests/api/model.steps.ts:222"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 7234292
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Then ",
+ "line": 114,
+ "name": "the response status should be 200",
+ "match": {
+ "location": "tests/shared/common.steps.ts:13"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 34999
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 115,
+ "name": "the response permissions action \"canView\" should be true",
+ "match": {
+ "location": "tests/api/model.steps.ts:246"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 26458
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 116,
+ "name": "the response permissions action \"canFork\" should be true",
+ "match": {
+ "location": "tests/api/model.steps.ts:246"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 16833
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 117,
+ "name": "the response permissions action \"canComment\" should be true",
+ "match": {
+ "location": "tests/api/model.steps.ts:246"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 10791
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 118,
+ "name": "the response permissions action \"canLike\" should be true",
+ "match": {
+ "location": "tests/api/model.steps.ts:246"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 9207
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 119,
+ "name": "the response permissions action \"canEdit\" should be false",
+ "match": {
+ "location": "tests/api/model.steps.ts:246"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 8874
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 120,
+ "name": "the response permissions action \"canDelete\" should be false",
+ "match": {
+ "location": "tests/api/model.steps.ts:246"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 8750
+ }
+ },
+ {
+ "keyword": "After",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 40999
+ },
+ "embeddings": [
+ {
+ "data": "U3RhdHVzOiBQQVNTRUQuIER1cmF0aW9uOjBz",
+ "mime_type": "text/plain"
+ }
+ ]
+ }
+ ],
+ "tags": [],
+ "type": "scenario"
+ },
+ {
+ "description": "",
+ "id": "model-management;resolving-an-unknown-legacy-model-id-returns-404",
+ "keyword": "Scenario",
+ "line": 123,
+ "name": "Resolving an unknown legacy model id returns 404",
+ "steps": [
+ {
+ "keyword": "Before",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 82721375
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "When ",
+ "line": 124,
+ "name": "I send a GET request to \"/api/v1/legacy/models/999999/resolve\"",
+ "match": {
+ "location": "tests/api/health.steps.ts:4"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 3805999
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Then ",
+ "line": 125,
+ "name": "the response status should be 404",
+ "match": {
+ "location": "tests/shared/common.steps.ts:13"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 26250
+ }
+ },
+ {
+ "keyword": "After",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 30334
+ },
+ "embeddings": [
+ {
+ "data": "U3RhdHVzOiBQQVNTRUQuIER1cmF0aW9uOjBz",
+ "mime_type": "text/plain"
+ }
+ ]
+ }
+ ],
+ "tags": [
+ {
+ "name": "@smoke",
+ "line": 122
+ }
+ ],
+ "type": "scenario"
+ },
+ {
+ "description": "",
+ "id": "model-management;resolving-a-legacy-id-with-an-invalid-value-is-rejected",
+ "keyword": "Scenario",
+ "line": 127,
+ "name": "Resolving a legacy id with an invalid value is rejected",
+ "steps": [
+ {
+ "keyword": "Before",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 72716999
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "When ",
+ "line": 128,
+ "name": "I send a GET request to \"/api/v1/legacy/models/0/resolve\"",
+ "match": {
+ "location": "tests/api/health.steps.ts:4"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 650125
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Then ",
+ "line": 129,
+ "name": "the response status should be 400",
+ "match": {
+ "location": "tests/shared/common.steps.ts:13"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 20125
+ }
+ },
+ {
+ "keyword": "After",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 26792
+ },
+ "embeddings": [
+ {
+ "data": "U3RhdHVzOiBQQVNTRUQuIER1cmF0aW9uOjBz",
+ "mime_type": "text/plain"
+ }
+ ]
+ }
+ ],
+ "tags": [],
+ "type": "scenario"
+ },
+ {
+ "description": "",
+ "id": "model-management;random-public-model-returns-id-and-title-when-public-models-exist",
+ "keyword": "Scenario",
+ "line": 131,
+ "name": "Random public model returns id and title when public models exist",
+ "steps": [
+ {
+ "keyword": "Before",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 121948208
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Given ",
+ "line": 132,
+ "name": "an authenticated user \"owner\"",
+ "match": {
+ "location": "tests/api/user.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 152850416
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 133,
+ "name": "a public model \"Random Candidate\" created by \"owner\"",
+ "match": {
+ "location": "tests/api/model.steps.ts:127"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 45206209
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "When ",
+ "line": 134,
+ "name": "I send a GET request to \"/api/v1/models/random\"",
+ "match": {
+ "location": "tests/api/health.steps.ts:4"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 4641499
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Then ",
+ "line": 135,
+ "name": "the response status should be 200",
+ "match": {
+ "location": "tests/shared/common.steps.ts:13"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 29416
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 136,
+ "name": "the response body should have property \"id\"",
+ "match": {
+ "location": "tests/shared/common.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 36458
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 137,
+ "name": "the response body should have property \"title\"",
+ "match": {
+ "location": "tests/shared/common.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 12417
+ }
+ },
+ {
+ "keyword": "After",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 38458
+ },
+ "embeddings": [
+ {
+ "data": "U3RhdHVzOiBQQVNTRUQuIER1cmF0aW9uOjBz",
+ "mime_type": "text/plain"
+ }
+ ]
+ }
+ ],
+ "tags": [],
+ "type": "scenario"
+ },
+ {
+ "description": "",
+ "id": "model-management;search-by-authorid-returns-only-models-authored-by-that-user",
+ "keyword": "Scenario",
+ "line": 139,
+ "name": "Search by authorId returns only models authored by that user",
+ "steps": [
+ {
+ "keyword": "Before",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 93694457
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Given ",
+ "line": 140,
+ "name": "an authenticated user \"alice\"",
+ "match": {
+ "location": "tests/api/user.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 149499457
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 141,
+ "name": "a public model \"Alice One\" created by \"alice\"",
+ "match": {
+ "location": "tests/api/model.steps.ts:127"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 42969458
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 142,
+ "name": "a public model \"Alice Two\" created by \"alice\"",
+ "match": {
+ "location": "tests/api/model.steps.ts:127"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 32543000
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 143,
+ "name": "an authenticated user \"bob\"",
+ "match": {
+ "location": "tests/api/user.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 152172875
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 144,
+ "name": "a public model \"Bob One\" created by \"bob\"",
+ "match": {
+ "location": "tests/api/model.steps.ts:127"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 42905582
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "When ",
+ "line": 145,
+ "name": "\"alice\" searches models filtered by author \"alice\"",
+ "match": {
+ "location": "tests/api/model.steps.ts:274"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 6086250
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Then ",
+ "line": 146,
+ "name": "the response status should be 200",
+ "match": {
+ "location": "tests/shared/common.steps.ts:13"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 31541
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 147,
+ "name": "the response body property \"data\" should have length 2",
+ "match": {
+ "location": "tests/shared/common.steps.ts:49"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 41624
+ }
+ },
+ {
+ "keyword": "After",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 46916
+ },
+ "embeddings": [
+ {
+ "data": "U3RhdHVzOiBQQVNTRUQuIER1cmF0aW9uOjBz",
+ "mime_type": "text/plain"
+ }
+ ]
+ }
+ ],
+ "tags": [],
+ "type": "scenario"
+ },
+ {
+ "description": "",
+ "id": "model-management;search-by-keyword-matches-version-title",
+ "keyword": "Scenario",
+ "line": 149,
+ "name": "Search by keyword matches version title",
+ "steps": [
+ {
+ "keyword": "Before",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 81245833
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Given ",
+ "line": 150,
+ "name": "an authenticated user \"owner\"",
+ "match": {
+ "location": "tests/api/user.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 155856416
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 151,
+ "name": "a public model \"Distinct Keyword Marker\" created by \"owner\"",
+ "match": {
+ "location": "tests/api/model.steps.ts:127"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 52055749
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 152,
+ "name": "a public model \"Other Model\" created by \"owner\"",
+ "match": {
+ "location": "tests/api/model.steps.ts:127"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 34904040
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "When ",
+ "line": 153,
+ "name": "\"owner\" sends a GET request to \"/api/v1/models?keyword=Distinct\"",
+ "match": {
+ "location": "tests/api/model.steps.ts:163"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 5042084
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Then ",
+ "line": 154,
+ "name": "the response status should be 200",
+ "match": {
+ "location": "tests/shared/common.steps.ts:13"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 29040
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 155,
+ "name": "the response body property \"data\" should have length 1",
+ "match": {
+ "location": "tests/shared/common.steps.ts:49"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 25000
+ }
+ },
+ {
+ "keyword": "After",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 38458
+ },
+ "embeddings": [
+ {
+ "data": "U3RhdHVzOiBQQVNTRUQuIER1cmF0aW9uOjBz",
+ "mime_type": "text/plain"
+ }
+ ]
+ }
+ ],
+ "tags": [],
+ "type": "scenario"
+ },
+ {
+ "description": "",
+ "id": "model-management;search-supports-sortby=recent-with-order=asc",
+ "keyword": "Scenario",
+ "line": 157,
+ "name": "Search supports sortBy=recent with order=asc",
+ "steps": [
+ {
+ "keyword": "Before",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 77343250
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Given ",
+ "line": 158,
+ "name": "an authenticated user \"owner\"",
+ "match": {
+ "location": "tests/api/user.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 144049833
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 159,
+ "name": "a public model \"First\" created by \"owner\"",
+ "match": {
+ "location": "tests/api/model.steps.ts:127"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 40415000
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 160,
+ "name": "a public model \"Second\" created by \"owner\"",
+ "match": {
+ "location": "tests/api/model.steps.ts:127"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 32066750
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "When ",
+ "line": 161,
+ "name": "\"owner\" sends a GET request to \"/api/v1/models?sortBy=recent&order=asc\"",
+ "match": {
+ "location": "tests/api/model.steps.ts:163"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 4733583
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Then ",
+ "line": 162,
+ "name": "the response status should be 200",
+ "match": {
+ "location": "tests/shared/common.steps.ts:13"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 26499
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 163,
+ "name": "the response body should have property \"data\" as an array",
+ "match": {
+ "location": "tests/shared/common.steps.ts:41"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 73083
+ }
+ },
+ {
+ "keyword": "After",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 32458
+ },
+ "embeddings": [
+ {
+ "data": "U3RhdHVzOiBQQVNTRUQuIER1cmF0aW9uOjBz",
+ "mime_type": "text/plain"
+ }
+ ]
+ }
+ ],
+ "tags": [],
+ "type": "scenario"
+ },
+ {
+ "description": "",
+ "id": "model-management;search-by-future-fromdate-returns-no-models",
+ "keyword": "Scenario",
+ "line": 165,
+ "name": "Search by future fromDate returns no models",
+ "steps": [
+ {
+ "keyword": "Before",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 75212333
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Given ",
+ "line": 166,
+ "name": "an authenticated user \"owner\"",
+ "match": {
+ "location": "tests/api/user.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 139793417
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 167,
+ "name": "a public model \"Past Model\" created by \"owner\"",
+ "match": {
+ "location": "tests/api/model.steps.ts:127"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 40475665
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "When ",
+ "line": 168,
+ "name": "\"owner\" sends a GET request to \"/api/v1/models?fromDate=2999-01-01\"",
+ "match": {
+ "location": "tests/api/model.steps.ts:163"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 5634083
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Then ",
+ "line": 169,
+ "name": "the response status should be 200",
+ "match": {
+ "location": "tests/shared/common.steps.ts:13"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 33540
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 170,
+ "name": "the response body property \"data\" should have length 0",
+ "match": {
+ "location": "tests/shared/common.steps.ts:49"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 22082
+ }
+ },
+ {
+ "keyword": "After",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 32500
+ },
+ "embeddings": [
+ {
+ "data": "U3RhdHVzOiBQQVNTRUQuIER1cmF0aW9uOjBz",
+ "mime_type": "text/plain"
+ }
+ ]
+ }
+ ],
+ "tags": [],
+ "type": "scenario"
+ },
+ {
+ "description": "",
+ "id": "model-management;search-sortby=views-orders-models-by-view-count-descending",
+ "keyword": "Scenario",
+ "line": 172,
+ "name": "Search sortBy=views orders models by view count descending",
+ "steps": [
+ {
+ "keyword": "Before",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 84950334
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Given ",
+ "line": 173,
+ "name": "an authenticated user \"owner\"",
+ "match": {
+ "location": "tests/api/user.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 159475417
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 174,
+ "name": "a public model \"Most Viewed\" created by \"owner\"",
+ "match": {
+ "location": "tests/api/model.steps.ts:127"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 53645415
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 175,
+ "name": "a public model \"Mid Viewed\" created by \"owner\"",
+ "match": {
+ "location": "tests/api/model.steps.ts:127"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 33052959
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 176,
+ "name": "a public model \"Least Viewed\" created by \"owner\"",
+ "match": {
+ "location": "tests/api/model.steps.ts:127"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 37267625
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 177,
+ "name": "an authenticated user \"viewer1\"",
+ "match": {
+ "location": "tests/api/user.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 160025083
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 178,
+ "name": "an authenticated user \"viewer2\"",
+ "match": {
+ "location": "tests/api/user.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 134975374
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 179,
+ "name": "an authenticated user \"viewer3\"",
+ "match": {
+ "location": "tests/api/user.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 134947458
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "When ",
+ "line": 180,
+ "name": "\"viewer1\" records a \"view\" interaction on \"Most Viewed\"",
+ "match": {
+ "location": "tests/api/model-interaction.steps.ts:58"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 16291125
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 181,
+ "name": "\"viewer2\" records a \"view\" interaction on \"Most Viewed\"",
+ "match": {
+ "location": "tests/api/model-interaction.steps.ts:58"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 8589291
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 182,
+ "name": "\"viewer3\" records a \"view\" interaction on \"Most Viewed\"",
+ "match": {
+ "location": "tests/api/model-interaction.steps.ts:58"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 4918166
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 183,
+ "name": "\"viewer1\" records a \"view\" interaction on \"Mid Viewed\"",
+ "match": {
+ "location": "tests/api/model-interaction.steps.ts:58"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 5723540
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 184,
+ "name": "\"viewer2\" records a \"view\" interaction on \"Mid Viewed\"",
+ "match": {
+ "location": "tests/api/model-interaction.steps.ts:58"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 4999334
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 185,
+ "name": "\"viewer1\" records a \"view\" interaction on \"Least Viewed\"",
+ "match": {
+ "location": "tests/api/model-interaction.steps.ts:58"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 6058417
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 186,
+ "name": "\"owner\" sends a GET request to \"/api/v1/models?sortBy=views&order=desc\"",
+ "match": {
+ "location": "tests/api/model.steps.ts:163"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 4311166
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Then ",
+ "line": 187,
+ "name": "the response status should be 200",
+ "match": {
+ "location": "tests/shared/common.steps.ts:13"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 23791
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 188,
+ "name": "the known models \"Most Viewed\", \"Mid Viewed\", \"Least Viewed\" appear in that relative order in the search results",
+ "match": {
+ "location": "tests/api/model.steps.ts:287"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 89416
+ }
+ },
+ {
+ "keyword": "After",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 28959
+ },
+ "embeddings": [
+ {
+ "data": "U3RhdHVzOiBQQVNTRUQuIER1cmF0aW9uOjBz",
+ "mime_type": "text/plain"
+ }
+ ]
+ }
+ ],
+ "tags": [],
+ "type": "scenario"
+ },
+ {
+ "description": "",
+ "id": "model-management;search-sortby=runs-orders-models-by-run-count-descending",
+ "keyword": "Scenario",
+ "line": 190,
+ "name": "Search sortBy=runs orders models by run count descending",
+ "steps": [
+ {
+ "keyword": "Before",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 77747666
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Given ",
+ "line": 191,
+ "name": "an authenticated user \"owner\"",
+ "match": {
+ "location": "tests/api/user.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 144614249
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 192,
+ "name": "a public model \"High Runs\" created by \"owner\"",
+ "match": {
+ "location": "tests/api/model.steps.ts:127"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 40022709
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 193,
+ "name": "a public model \"Low Runs\" created by \"owner\"",
+ "match": {
+ "location": "tests/api/model.steps.ts:127"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 29937040
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 194,
+ "name": "an authenticated user \"runner\"",
+ "match": {
+ "location": "tests/api/user.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 138462665
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "When ",
+ "line": 195,
+ "name": "\"runner\" records a \"run\" interaction on \"High Runs\"",
+ "match": {
+ "location": "tests/api/model-interaction.steps.ts:58"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 12145082
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 196,
+ "name": "\"runner\" records a \"run\" interaction on \"High Runs\"",
+ "match": {
+ "location": "tests/api/model-interaction.steps.ts:58"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 6646542
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 197,
+ "name": "\"runner\" records a \"run\" interaction on \"Low Runs\"",
+ "match": {
+ "location": "tests/api/model-interaction.steps.ts:58"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 4395041
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 198,
+ "name": "\"owner\" sends a GET request to \"/api/v1/models?sortBy=runs&order=desc\"",
+ "match": {
+ "location": "tests/api/model.steps.ts:163"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 3063957
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Then ",
+ "line": 199,
+ "name": "the response status should be 200",
+ "match": {
+ "location": "tests/shared/common.steps.ts:13"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 22499
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 200,
+ "name": "the known models \"High Runs\", \"Low Runs\" appear in that relative order in the search results",
+ "match": {
+ "location": "tests/api/model.steps.ts:309"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 140333
+ }
+ },
+ {
+ "keyword": "After",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 38875
+ },
+ "embeddings": [
+ {
+ "data": "U3RhdHVzOiBQQVNTRUQuIER1cmF0aW9uOjBz",
+ "mime_type": "text/plain"
+ }
+ ]
+ }
+ ],
+ "tags": [],
+ "type": "scenario"
+ }
+ ],
+ "id": "model-management",
+ "line": 1,
+ "keyword": "Feature",
+ "name": "Model Management",
+ "tags": [],
+ "uri": "tests/api/model.feature"
+ },
+ {
+ "description": " As a user composing a model\n I want to look up available NetLogo runtime versions\n So that I can pin a model to a specific runtime",
+ "elements": [
+ {
+ "description": "",
+ "id": "netlogo-versions;anonymous-access-returns-an-array",
+ "keyword": "Scenario",
+ "line": 6,
+ "name": "Anonymous access returns an array",
+ "steps": [
+ {
+ "keyword": "Before",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 87331042
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "When ",
+ "line": 7,
+ "name": "I send a GET request to \"/api/v1/netlogo-versions\"",
+ "match": {
+ "location": "tests/api/health.steps.ts:4"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 3036584
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Then ",
+ "line": 8,
+ "name": "the response status should be 200",
+ "match": {
+ "location": "tests/shared/common.steps.ts:13"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 55249
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 9,
+ "name": "the response body should be an array",
+ "match": {
+ "location": "tests/shared/common.steps.ts:33"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 29000
+ }
+ },
+ {
+ "keyword": "After",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 47040
+ },
+ "embeddings": [
+ {
+ "data": "U3RhdHVzOiBQQVNTRUQuIER1cmF0aW9uOjBz",
+ "mime_type": "text/plain"
+ }
+ ]
+ }
+ ],
+ "tags": [],
+ "type": "scenario"
+ },
+ {
+ "description": "",
+ "id": "netlogo-versions;filtering-by-prefix-returns-an-array",
+ "keyword": "Scenario",
+ "line": 11,
+ "name": "Filtering by prefix returns an array",
+ "steps": [
+ {
+ "keyword": "Before",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 78847541
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "When ",
+ "line": 12,
+ "name": "I send a GET request to \"/api/v1/netlogo-versions?prefix=6\"",
+ "match": {
+ "location": "tests/api/health.steps.ts:4"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 2387458
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Then ",
+ "line": 13,
+ "name": "the response status should be 200",
+ "match": {
+ "location": "tests/shared/common.steps.ts:13"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 30416
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 14,
+ "name": "the response body should be an array",
+ "match": {
+ "location": "tests/shared/common.steps.ts:33"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 10957
+ }
+ },
+ {
+ "keyword": "After",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 28333
+ },
+ "embeddings": [
+ {
+ "data": "U3RhdHVzOiBQQVNTRUQuIER1cmF0aW9uOjBz",
+ "mime_type": "text/plain"
+ }
+ ]
+ }
+ ],
+ "tags": [],
+ "type": "scenario"
+ },
+ {
+ "description": "",
+ "id": "netlogo-versions;empty-prefix-is-accepted",
+ "keyword": "Scenario",
+ "line": 16,
+ "name": "Empty prefix is accepted",
+ "steps": [
+ {
+ "keyword": "Before",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 79133792
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "When ",
+ "line": 17,
+ "name": "I send a GET request to \"/api/v1/netlogo-versions?prefix=\"",
+ "match": {
+ "location": "tests/api/health.steps.ts:4"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 1957541
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Then ",
+ "line": 18,
+ "name": "the response status should be 200",
+ "match": {
+ "location": "tests/shared/common.steps.ts:13"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 24165
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 19,
+ "name": "the response body should be an array",
+ "match": {
+ "location": "tests/shared/common.steps.ts:33"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 12542
+ }
+ },
+ {
+ "keyword": "After",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 24582
+ },
+ "embeddings": [
+ {
+ "data": "U3RhdHVzOiBQQVNTRUQuIER1cmF0aW9uOjBz",
+ "mime_type": "text/plain"
+ }
+ ]
+ }
+ ],
+ "tags": [],
+ "type": "scenario"
+ },
+ {
+ "description": "",
+ "id": "netlogo-versions;excessively-long-prefix-is-rejected",
+ "keyword": "Scenario",
+ "line": 21,
+ "name": "Excessively long prefix is rejected",
+ "steps": [
+ {
+ "keyword": "Before",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 72569624
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "When ",
+ "line": 22,
+ "name": "I send a GET request to \"/api/v1/netlogo-versions?prefix=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"",
+ "match": {
+ "location": "tests/api/health.steps.ts:4"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 979166
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Then ",
+ "line": 23,
+ "name": "the response status should be 400",
+ "match": {
+ "location": "tests/shared/common.steps.ts:13"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 21083
+ }
+ },
+ {
+ "keyword": "After",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 24958
+ },
+ "embeddings": [
+ {
+ "data": "U3RhdHVzOiBQQVNTRUQuIER1cmF0aW9uOjBz",
+ "mime_type": "text/plain"
+ }
+ ]
+ }
+ ],
+ "tags": [],
+ "type": "scenario"
+ }
+ ],
+ "id": "netlogo-versions",
+ "line": 1,
+ "keyword": "Feature",
+ "name": "NetLogo Versions",
+ "tags": [],
+ "uri": "tests/api/netlogo-versions.feature"
+ },
+ {
+ "description": " These scenarios pin down audited vulnerabilities so reverts of the fix\n are caught by CI. Scenarios tagged @security-todo capture present-day\n (still-insecure) behaviour and must be tightened when the fix lands.",
+ "elements": [
+ {
+ "description": "",
+ "id": "security-regressions;the-unauthenticated-/v1/dev/fill-in-route-is-gone",
+ "keyword": "Scenario",
+ "line": 9,
+ "name": "The unauthenticated /v1/dev/fill-in route is gone",
+ "steps": [
+ {
+ "keyword": "Before",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 76049792
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "When ",
+ "line": 10,
+ "name": "an anonymous viewer sends a GET request to \"/api/v1/dev/fill-in\"",
+ "match": {
+ "location": "tests/api/security.steps.ts:42"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 691333
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Then ",
+ "line": 11,
+ "name": "the response status should be 404",
+ "match": {
+ "location": "tests/shared/common.steps.ts:13"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 21957
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "When ",
+ "line": 12,
+ "name": "an anonymous viewer sends a POST request to \"/api/v1/dev/fill-in\"",
+ "match": {
+ "location": "tests/api/security.steps.ts:49"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 293541
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Then ",
+ "line": 13,
+ "name": "the response status should be 404",
+ "match": {
+ "location": "tests/shared/common.steps.ts:13"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 10749
+ }
+ },
+ {
+ "keyword": "After",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 25125
+ },
+ "embeddings": [
+ {
+ "data": "U3RhdHVzOiBQQVNTRUQuIER1cmF0aW9uOjBz",
+ "mime_type": "text/plain"
+ }
+ ]
+ }
+ ],
+ "tags": [
+ {
+ "name": "@security",
+ "line": 8
+ }
+ ],
+ "type": "scenario"
+ },
+ {
+ "description": "",
+ "id": "security-regressions;a-regular-user-cannot-self-elevate-systemrole-via-patch-/v1/users/:id",
+ "keyword": "Scenario",
+ "line": 21,
+ "name": "A regular user cannot self-elevate systemRole via PATCH /v1/users/:id",
+ "steps": [
+ {
+ "keyword": "Before",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 74076958
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Given ",
+ "line": 22,
+ "name": "an authenticated user",
+ "match": {
+ "location": "tests/api/user.steps.ts:11"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 170514209
+ }
+ },
+ {
+ "arguments": [
+ {
+ "content": "{\"systemRole\": \"admin\"}",
+ "line": 24
+ }
+ ],
+ "keyword": "When ",
+ "line": 23,
+ "name": "the current user PATCHes their own profile with JSON body:",
+ "match": {
+ "location": "tests/api/security.steps.ts:72"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 4494540
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Then ",
+ "line": 27,
+ "name": "the response status should be 403",
+ "match": {
+ "location": "tests/shared/common.steps.ts:13"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 39041
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "When ",
+ "line": 28,
+ "name": "the current user fetches their own profile",
+ "match": {
+ "location": "tests/api/security.steps.ts:85"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 9347874
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Then ",
+ "line": 29,
+ "name": "the response status should be 200",
+ "match": {
+ "location": "tests/shared/common.steps.ts:13"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 34541
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 30,
+ "name": "the response body property \"systemRole\" should equal \"user\"",
+ "match": {
+ "location": "tests/shared/common.steps.ts:25"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 43750
+ }
+ },
+ {
+ "keyword": "After",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 49374
+ },
+ "embeddings": [
+ {
+ "data": "U3RhdHVzOiBQQVNTRUQuIER1cmF0aW9uOjBz",
+ "mime_type": "text/plain"
+ }
+ ]
+ }
+ ],
+ "tags": [
+ {
+ "name": "@security",
+ "line": 20
+ }
+ ],
+ "type": "scenario"
+ },
+ {
+ "description": "",
+ "id": "security-regressions;soft-deleting-a-user-invalidates-their-existing-session-cookie",
+ "keyword": "Scenario",
+ "line": 35,
+ "name": "Soft-deleting a user invalidates their existing session cookie",
+ "steps": [
+ {
+ "keyword": "Before",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 78781583
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Given ",
+ "line": 36,
+ "name": "an authenticated user",
+ "match": {
+ "location": "tests/api/user.steps.ts:11"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 156969541
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "When ",
+ "line": 37,
+ "name": "I delete the current user's account",
+ "match": {
+ "location": "tests/api/user.steps.ts:46"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 11711582
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Then ",
+ "line": 38,
+ "name": "the response status should be 204",
+ "match": {
+ "location": "tests/shared/common.steps.ts:13"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 60375
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "When ",
+ "line": 39,
+ "name": "the current user fetches \"/api/v1/users/whoami\" with their old cookie",
+ "match": {
+ "location": "tests/api/security.steps.ts:94"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 1939707
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Then ",
+ "line": 40,
+ "name": "the response status should be 401",
+ "match": {
+ "location": "tests/shared/common.steps.ts:13"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 15790
+ }
+ },
+ {
+ "keyword": "After",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 33084
+ },
+ "embeddings": [
+ {
+ "data": "U3RhdHVzOiBQQVNTRUQuIER1cmF0aW9uOjBz",
+ "mime_type": "text/plain"
+ }
+ ]
+ }
+ ],
+ "tags": [
+ {
+ "name": "@security",
+ "line": 34
+ }
+ ],
+ "type": "scenario"
+ },
+ {
+ "description": "",
+ "id": "security-regressions;swagger-docs-are-reachable-without-auth-in-non-production",
+ "keyword": "Scenario",
+ "line": 46,
+ "name": "Swagger docs are reachable without auth in non-production",
+ "steps": [
+ {
+ "keyword": "Before",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 139504000
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "When ",
+ "line": 49,
+ "name": "an anonymous viewer sends a GET request to \"/api-docs/auth/openapi.json\"",
+ "match": {
+ "location": "tests/api/security.steps.ts:42"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 3589374
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Then ",
+ "line": 50,
+ "name": "the response status should not be 401",
+ "match": {
+ "location": "tests/api/security.steps.ts:61"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 57749
+ }
+ },
+ {
+ "keyword": "After",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 25999
+ },
+ "embeddings": [
+ {
+ "data": "U3RhdHVzOiBQQVNTRUQuIER1cmF0aW9uOjBz",
+ "mime_type": "text/plain"
+ }
+ ]
+ }
+ ],
+ "tags": [
+ {
+ "name": "@security",
+ "line": 45
+ },
+ {
+ "name": "@security-todo",
+ "line": 45
+ }
+ ],
+ "type": "scenario"
+ },
+ {
+ "description": "",
+ "id": "security-regressions;avatar-upload-rejects-mime-spoofing-(svg-body-sent-as-image/png)",
+ "keyword": "Scenario",
+ "line": 55,
+ "name": "Avatar upload rejects MIME spoofing (svg body sent as image/png)",
+ "steps": [
+ {
+ "keyword": "Before",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 96918791
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Given ",
+ "line": 56,
+ "name": "an authenticated user",
+ "match": {
+ "location": "tests/api/user.steps.ts:11"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 160529167
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "When ",
+ "line": 57,
+ "name": "I upload an avatar declared \"image/png\" with an SVG payload",
+ "match": {
+ "location": "tests/api/security.steps.ts:106"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 3417083
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Then ",
+ "line": 58,
+ "name": "the response status should be 400",
+ "match": {
+ "location": "tests/shared/common.steps.ts:13"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 26458
+ }
+ },
+ {
+ "keyword": "After",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 44666
+ },
+ "embeddings": [
+ {
+ "data": "U3RhdHVzOiBQQVNTRUQuIER1cmF0aW9uOjBz",
+ "mime_type": "text/plain"
+ }
+ ]
+ }
+ ],
+ "tags": [
+ {
+ "name": "@security",
+ "line": 54
+ }
+ ],
+ "type": "scenario"
+ },
+ {
+ "description": "",
+ "id": "security-regressions;additional-file-upload-still-accepts-script-like-mime-types",
+ "keyword": "Scenario Outline",
+ "line": 73,
+ "name": "Additional-file upload still accepts script-like MIME types",
+ "steps": [
+ {
+ "keyword": "Before",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 91774457
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Given ",
+ "line": 66,
+ "name": "an authenticated user \"owner\"",
+ "match": {
+ "location": "tests/api/user.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 161869209
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 67,
+ "name": "a public model \"Attach Sim\" created by \"owner\"",
+ "match": {
+ "location": "tests/api/model.steps.ts:127"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 52807458
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "When ",
+ "line": 68,
+ "name": "\"owner\" uploads an additional file declared \"text/html\" with body \"\" to \"Attach Sim\"",
+ "match": {
+ "location": "tests/api/security.steps.ts:128"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 17200624
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Then ",
+ "line": 69,
+ "name": "the response status should be 201",
+ "match": {
+ "location": "tests/shared/common.steps.ts:13"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 37458
+ }
+ },
+ {
+ "keyword": "After",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 37582
+ },
+ "embeddings": [
+ {
+ "data": "U3RhdHVzOiBQQVNTRUQuIER1cmF0aW9uOjBz",
+ "mime_type": "text/plain"
+ }
+ ]
+ }
+ ],
+ "tags": [
+ {
+ "name": "@security",
+ "line": 63
+ },
+ {
+ "name": "@security-todo",
+ "line": 63
+ }
+ ],
+ "type": "scenario"
+ },
+ {
+ "description": "",
+ "id": "security-regressions;additional-file-upload-still-accepts-script-like-mime-types",
+ "keyword": "Scenario Outline",
+ "line": 74,
+ "name": "Additional-file upload still accepts script-like MIME types",
+ "steps": [
+ {
+ "keyword": "Before",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 81642542
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Given ",
+ "line": 66,
+ "name": "an authenticated user \"owner\"",
+ "match": {
+ "location": "tests/api/user.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 162861958
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 67,
+ "name": "a public model \"Attach Sim\" created by \"owner\"",
+ "match": {
+ "location": "tests/api/model.steps.ts:127"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 49122250
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "When ",
+ "line": 68,
+ "name": "\"owner\" uploads an additional file declared \"application/javascript\" with body \"alert(1);\" to \"Attach Sim\"",
+ "match": {
+ "location": "tests/api/security.steps.ts:128"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 16197166
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Then ",
+ "line": 69,
+ "name": "the response status should be 201",
+ "match": {
+ "location": "tests/shared/common.steps.ts:13"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 34625
+ }
+ },
+ {
+ "keyword": "After",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 32459
+ },
+ "embeddings": [
+ {
+ "data": "U3RhdHVzOiBQQVNTRUQuIER1cmF0aW9uOjBz",
+ "mime_type": "text/plain"
+ }
+ ]
+ }
+ ],
+ "tags": [
+ {
+ "name": "@security",
+ "line": 63
+ },
+ {
+ "name": "@security-todo",
+ "line": 63
+ }
+ ],
+ "type": "scenario"
+ },
+ {
+ "description": "",
+ "id": "security-regressions;deleting-an-additional-file-from-a-different-model-returns-404",
+ "keyword": "Scenario",
+ "line": 80,
+ "name": "Deleting an additional file from a different model returns 404",
+ "steps": [
+ {
+ "keyword": "Before",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 81358166
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Given ",
+ "line": 81,
+ "name": "an authenticated user \"alice\"",
+ "match": {
+ "location": "tests/api/user.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 148305874
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 82,
+ "name": "a public model \"Model A\" created by \"alice\"",
+ "match": {
+ "location": "tests/api/model.steps.ts:127"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 46275459
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 83,
+ "name": "\"alice\" has uploaded an additional file \"fileA\" to \"Model A\"",
+ "match": {
+ "location": "tests/api/model-additional-file.steps.ts:123"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 16949667
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 84,
+ "name": "an authenticated user \"bob\"",
+ "match": {
+ "location": "tests/api/user.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 136748999
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 85,
+ "name": "a public model \"Model B\" created by \"bob\"",
+ "match": {
+ "location": "tests/api/model.steps.ts:127"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 35510416
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "When ",
+ "line": 86,
+ "name": "\"bob\" deletes additional file \"fileA\" using the modelId of \"Model B\"",
+ "match": {
+ "location": "tests/api/security.steps.ts:154"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 4997624
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Then ",
+ "line": 87,
+ "name": "the response status should be 404",
+ "match": {
+ "location": "tests/shared/common.steps.ts:13"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 25374
+ }
+ },
+ {
+ "keyword": "After",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 33707
+ },
+ "embeddings": [
+ {
+ "data": "U3RhdHVzOiBQQVNTRUQuIER1cmF0aW9uOjBz",
+ "mime_type": "text/plain"
+ }
+ ]
+ }
+ ],
+ "tags": [
+ {
+ "name": "@security",
+ "line": 79
+ }
+ ],
+ "type": "scenario"
+ }
+ ],
+ "id": "security-regressions",
+ "line": 1,
+ "keyword": "Feature",
+ "name": "Security Regressions",
+ "tags": [],
+ "uri": "tests/api/security.feature"
+ },
+ {
+ "description": " As a user\n I want to browse and search tags\n So that I can discover models by topic",
+ "elements": [
+ {
+ "description": "",
+ "id": "tag-management;list-tags-returns-paginated-results",
+ "keyword": "Scenario",
+ "line": 6,
+ "name": "List tags returns paginated results",
+ "steps": [
+ {
+ "keyword": "Before",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 83093874
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Given ",
+ "line": 7,
+ "name": "a tag \"ecology\" exists",
+ "match": {
+ "location": "tests/api/tag.steps.ts:4"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 2840209
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "When ",
+ "line": 8,
+ "name": "I send a GET request to \"/api/v1/tags\"",
+ "match": {
+ "location": "tests/api/health.steps.ts:4"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 2372957
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Then ",
+ "line": 9,
+ "name": "the response status should be 200",
+ "match": {
+ "location": "tests/shared/common.steps.ts:13"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 37041
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 10,
+ "name": "the response body should have property \"data\" as an array",
+ "match": {
+ "location": "tests/shared/common.steps.ts:41"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 46458
+ }
+ },
+ {
+ "keyword": "After",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 30250
+ },
+ "embeddings": [
+ {
+ "data": "U3RhdHVzOiBQQVNTRUQuIER1cmF0aW9uOjBz",
+ "mime_type": "text/plain"
+ }
+ ]
+ }
+ ],
+ "tags": [],
+ "type": "scenario"
+ },
+ {
+ "description": "",
+ "id": "tag-management;search-tags-by-prefix",
+ "keyword": "Scenario",
+ "line": 12,
+ "name": "Search tags by prefix",
+ "steps": [
+ {
+ "keyword": "Before",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 70032749
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Given ",
+ "line": 13,
+ "name": "a tag \"biology\" exists",
+ "match": {
+ "location": "tests/api/tag.steps.ts:4"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 2311582
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 14,
+ "name": "a tag \"biochemistry\" exists",
+ "match": {
+ "location": "tests/api/tag.steps.ts:4"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 637624
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "When ",
+ "line": 15,
+ "name": "I send a GET request to \"/api/v1/tags?q=bio\"",
+ "match": {
+ "location": "tests/api/health.steps.ts:4"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 1431457
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Then ",
+ "line": 16,
+ "name": "the response status should be 200",
+ "match": {
+ "location": "tests/shared/common.steps.ts:13"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 18833
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 17,
+ "name": "the response body should have property \"data\" as an array",
+ "match": {
+ "location": "tests/shared/common.steps.ts:41"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 22334
+ }
+ },
+ {
+ "keyword": "After",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 90540
+ },
+ "embeddings": [
+ {
+ "data": "U3RhdHVzOiBQQVNTRUQuIER1cmF0aW9uOjBz",
+ "mime_type": "text/plain"
+ }
+ ]
+ }
+ ],
+ "tags": [],
+ "type": "scenario"
+ },
+ {
+ "description": "",
+ "id": "tag-management;get-a-tag-by-name",
+ "keyword": "Scenario",
+ "line": 19,
+ "name": "Get a tag by name",
+ "steps": [
+ {
+ "keyword": "Before",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 70990416
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Given ",
+ "line": 20,
+ "name": "a tag \"physics\" exists",
+ "match": {
+ "location": "tests/api/tag.steps.ts:4"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 2322707
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "When ",
+ "line": 21,
+ "name": "I get the tag \"physics\"",
+ "match": {
+ "location": "tests/api/tag.steps.ts:13"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 1986625
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Then ",
+ "line": 22,
+ "name": "the response status should be 200",
+ "match": {
+ "location": "tests/shared/common.steps.ts:13"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 42708
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 23,
+ "name": "the response body property \"name\" should equal \"physics\"",
+ "match": {
+ "location": "tests/shared/common.steps.ts:25"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 372250
+ }
+ },
+ {
+ "keyword": "After",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 110000
+ },
+ "embeddings": [
+ {
+ "data": "U3RhdHVzOiBQQVNTRUQuIER1cmF0aW9uOjBz",
+ "mime_type": "text/plain"
+ }
+ ]
+ }
+ ],
+ "tags": [],
+ "type": "scenario"
+ },
+ {
+ "description": "",
+ "id": "tag-management;get-a-non-existent-tag-returns-404",
+ "keyword": "Scenario",
+ "line": 25,
+ "name": "Get a non-existent tag returns 404",
+ "steps": [
+ {
+ "keyword": "Before",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 78375875
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "When ",
+ "line": 26,
+ "name": "I send a GET request to \"/api/v1/tags/nonexistent-tag-xyz\"",
+ "match": {
+ "location": "tests/api/health.steps.ts:4"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 2371040
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Then ",
+ "line": 27,
+ "name": "the response status should be 404",
+ "match": {
+ "location": "tests/shared/common.steps.ts:13"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 27709
+ }
+ },
+ {
+ "keyword": "After",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 27208
+ },
+ "embeddings": [
+ {
+ "data": "U3RhdHVzOiBQQVNTRUQuIER1cmF0aW9uOjBz",
+ "mime_type": "text/plain"
+ }
+ ]
+ }
+ ],
+ "tags": [],
+ "type": "scenario"
+ },
+ {
+ "description": "",
+ "id": "tag-management;list-popular-tags-returns-a-paginated-payload",
+ "keyword": "Scenario",
+ "line": 30,
+ "name": "List popular tags returns a paginated payload",
+ "steps": [
+ {
+ "keyword": "Before",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 68993666
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "When ",
+ "line": 31,
+ "name": "I send a GET request to \"/api/v1/tags/popular\"",
+ "match": {
+ "location": "tests/api/health.steps.ts:4"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 5077792
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Then ",
+ "line": 32,
+ "name": "the response status should be 200",
+ "match": {
+ "location": "tests/shared/common.steps.ts:13"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 38416
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 33,
+ "name": "the response body should have property \"data\" as an array",
+ "match": {
+ "location": "tests/shared/common.steps.ts:41"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 55916
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 34,
+ "name": "the response body should have property \"count\"",
+ "match": {
+ "location": "tests/shared/common.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 82374
+ }
+ },
+ {
+ "keyword": "After",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 51582
+ },
+ "embeddings": [
+ {
+ "data": "U3RhdHVzOiBQQVNTRUQuIER1cmF0aW9uOjBz",
+ "mime_type": "text/plain"
+ }
+ ]
+ }
+ ],
+ "tags": [
+ {
+ "name": "@smoke",
+ "line": 29
+ }
+ ],
+ "type": "scenario"
+ }
+ ],
+ "id": "tag-management",
+ "line": 1,
+ "keyword": "Feature",
+ "name": "Tag Management",
+ "tags": [],
+ "uri": "tests/api/tag.feature"
+ },
+ {
+ "description": " As a user\n I want to manage user profiles\n So that I can view and update user information",
+ "elements": [
+ {
+ "description": "",
+ "id": "user-management;list-users-returns-paginated-results",
+ "keyword": "Scenario",
+ "line": 6,
+ "name": "List users returns paginated results",
+ "steps": [
+ {
+ "keyword": "Before",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 75046167
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Given ",
+ "line": 7,
+ "name": "an authenticated user",
+ "match": {
+ "location": "tests/api/user.steps.ts:11"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 149951375
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "When ",
+ "line": 8,
+ "name": "I send a GET request to \"/api/v1/users\"",
+ "match": {
+ "location": "tests/api/health.steps.ts:4"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 5183707
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Then ",
+ "line": 9,
+ "name": "the response status should be 200",
+ "match": {
+ "location": "tests/shared/common.steps.ts:13"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 32666
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 10,
+ "name": "the response body should have property \"data\" as an array",
+ "match": {
+ "location": "tests/shared/common.steps.ts:41"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 22958
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 11,
+ "name": "the response body should have property \"count\"",
+ "match": {
+ "location": "tests/shared/common.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 12708
+ }
+ },
+ {
+ "keyword": "After",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 32375
+ },
+ "embeddings": [
+ {
+ "data": "U3RhdHVzOiBQQVNTRUQuIER1cmF0aW9uOjBz",
+ "mime_type": "text/plain"
+ }
+ ]
+ }
+ ],
+ "tags": [],
+ "type": "scenario"
+ },
+ {
+ "description": "",
+ "id": "user-management;get-user-by-id",
+ "keyword": "Scenario",
+ "line": 13,
+ "name": "Get user by ID",
+ "steps": [
+ {
+ "keyword": "Before",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 74257999
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Given ",
+ "line": 14,
+ "name": "an authenticated user",
+ "match": {
+ "location": "tests/api/user.steps.ts:11"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 156522124
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "When ",
+ "line": 15,
+ "name": "I get the current user's profile",
+ "match": {
+ "location": "tests/api/user.steps.ts:24"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 3862583
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Then ",
+ "line": 16,
+ "name": "the response status should be 200",
+ "match": {
+ "location": "tests/shared/common.steps.ts:13"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 40208
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 17,
+ "name": "the response body should have property \"id\"",
+ "match": {
+ "location": "tests/shared/common.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 30167
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 18,
+ "name": "the response body should have property \"emailVerified\"",
+ "match": {
+ "location": "tests/shared/common.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 14042
+ }
+ },
+ {
+ "keyword": "After",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 58375
+ },
+ "embeddings": [
+ {
+ "data": "U3RhdHVzOiBQQVNTRUQuIER1cmF0aW9uOjBz",
+ "mime_type": "text/plain"
+ }
+ ]
+ }
+ ],
+ "tags": [],
+ "type": "scenario"
+ },
+ {
+ "description": "",
+ "id": "user-management;update-own-profile",
+ "keyword": "Scenario",
+ "line": 20,
+ "name": "Update own profile",
+ "steps": [
+ {
+ "keyword": "Before",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 80265916
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Given ",
+ "line": 21,
+ "name": "an authenticated user",
+ "match": {
+ "location": "tests/api/user.steps.ts:11"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 150882582
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "When ",
+ "line": 22,
+ "name": "I update the current user with userKind \"student\"",
+ "match": {
+ "location": "tests/api/user.steps.ts:33"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 7963499
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Then ",
+ "line": 23,
+ "name": "the response status should be 204",
+ "match": {
+ "location": "tests/shared/common.steps.ts:13"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 42959
+ }
+ },
+ {
+ "keyword": "After",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 28624
+ },
+ "embeddings": [
+ {
+ "data": "U3RhdHVzOiBQQVNTRUQuIER1cmF0aW9uOjBz",
+ "mime_type": "text/plain"
+ }
+ ]
+ }
+ ],
+ "tags": [],
+ "type": "scenario"
+ },
+ {
+ "description": "",
+ "id": "user-management;update-profile-requires-authentication",
+ "keyword": "Scenario",
+ "line": 25,
+ "name": "Update profile requires authentication",
+ "steps": [
+ {
+ "keyword": "Before",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 75583707
+ }
+ },
+ {
+ "arguments": [
+ {
+ "rows": [
+ {
+ "cells": [
+ "userKind",
+ "student"
+ ]
+ }
+ ]
+ }
+ ],
+ "keyword": "When ",
+ "line": 26,
+ "name": "I send a PATCH request to \"/api/v1/users/00000000-0000-0000-0000-000000000000\" with body:",
+ "match": {
+ "location": "tests/api/user.steps.ts:55"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 744750
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Then ",
+ "line": 28,
+ "name": "the response status should be 401",
+ "match": {
+ "location": "tests/shared/common.steps.ts:13"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 16166
+ }
+ },
+ {
+ "keyword": "After",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 18875
+ },
+ "embeddings": [
+ {
+ "data": "U3RhdHVzOiBQQVNTRUQuIER1cmF0aW9uOjBz",
+ "mime_type": "text/plain"
+ }
+ ]
+ }
+ ],
+ "tags": [],
+ "type": "scenario"
+ },
+ {
+ "description": "",
+ "id": "user-management;delete-own-account",
+ "keyword": "Scenario",
+ "line": 30,
+ "name": "Delete own account",
+ "steps": [
+ {
+ "keyword": "Before",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 83854499
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Given ",
+ "line": 31,
+ "name": "an authenticated user",
+ "match": {
+ "location": "tests/api/user.steps.ts:11"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 154414916
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "When ",
+ "line": 32,
+ "name": "I delete the current user's account",
+ "match": {
+ "location": "tests/api/user.steps.ts:46"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 9861749
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Then ",
+ "line": 33,
+ "name": "the response status should be 204",
+ "match": {
+ "location": "tests/shared/common.steps.ts:13"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 46167
+ }
+ },
+ {
+ "keyword": "After",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 38458
+ },
+ "embeddings": [
+ {
+ "data": "U3RhdHVzOiBQQVNTRUQuIER1cmF0aW9uOjBz",
+ "mime_type": "text/plain"
+ }
+ ]
+ }
+ ],
+ "tags": [],
+ "type": "scenario"
+ },
+ {
+ "description": "",
+ "id": "user-management;cannot-update-another-user's-profile",
+ "keyword": "Scenario",
+ "line": 35,
+ "name": "Cannot update another user's profile",
+ "steps": [
+ {
+ "keyword": "Before",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 123416708
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Given ",
+ "line": 36,
+ "name": "an authenticated user \"alice\"",
+ "match": {
+ "location": "tests/api/user.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 154727875
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 37,
+ "name": "an authenticated user \"bob\"",
+ "match": {
+ "location": "tests/api/user.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 147634125
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "When ",
+ "line": 38,
+ "name": "\"alice\" updates \"bob\" with userKind \"teacher\"",
+ "match": {
+ "location": "tests/api/user.steps.ts:74"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 5484875
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Then ",
+ "line": 39,
+ "name": "the response status should be 403",
+ "match": {
+ "location": "tests/shared/common.steps.ts:13"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 32291
+ }
+ },
+ {
+ "keyword": "After",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 38957
+ },
+ "embeddings": [
+ {
+ "data": "U3RhdHVzOiBQQVNTRUQuIER1cmF0aW9uOjBz",
+ "mime_type": "text/plain"
+ }
+ ]
+ }
+ ],
+ "tags": [],
+ "type": "scenario"
+ },
+ {
+ "description": "",
+ "id": "user-management;private-profile-returns-only-public-fields-to-other-users",
+ "keyword": "Scenario",
+ "line": 41,
+ "name": "Private profile returns only public fields to other users",
+ "steps": [
+ {
+ "keyword": "Before",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 110618792
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Given ",
+ "line": 42,
+ "name": "an authenticated user \"alice\"",
+ "match": {
+ "location": "tests/api/user.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 147246708
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 43,
+ "name": "\"alice\" sets their profile to private",
+ "match": {
+ "location": "tests/api/user.steps.ts:89"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 8367416
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 44,
+ "name": "an authenticated user \"bob\"",
+ "match": {
+ "location": "tests/api/user.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 142146625
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "When ",
+ "line": 45,
+ "name": "\"bob\" gets the profile of \"alice\"",
+ "match": {
+ "location": "tests/api/user.steps.ts:115"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 7714875
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Then ",
+ "line": 46,
+ "name": "the response status should be 200",
+ "match": {
+ "location": "tests/shared/common.steps.ts:13"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 35334
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 47,
+ "name": "the response body should have property \"name\"",
+ "match": {
+ "location": "tests/shared/common.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 37167
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 48,
+ "name": "the response body should have property \"isProfilePublic\" equal to false",
+ "match": {
+ "location": "tests/shared/common.steps.ts:73"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 21042
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 49,
+ "name": "the response body should have property \"image\"",
+ "match": {
+ "location": "tests/shared/common.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 10041
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 50,
+ "name": "the response body should not have property \"email\"",
+ "match": {
+ "location": "tests/shared/common.steps.ts:57"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 18541
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 51,
+ "name": "the response body should not have property \"systemRole\"",
+ "match": {
+ "location": "tests/shared/common.steps.ts:57"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 8583
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 52,
+ "name": "the response body should not have property \"userKind\"",
+ "match": {
+ "location": "tests/shared/common.steps.ts:57"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 8040
+ }
+ },
+ {
+ "keyword": "After",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 39917
+ },
+ "embeddings": [
+ {
+ "data": "U3RhdHVzOiBQQVNTRUQuIER1cmF0aW9uOjBz",
+ "mime_type": "text/plain"
+ }
+ ]
+ }
+ ],
+ "tags": [],
+ "type": "scenario"
+ },
+ {
+ "description": "",
+ "id": "user-management;private-profile-returns-full-fields-to-the-owner",
+ "keyword": "Scenario",
+ "line": 54,
+ "name": "Private profile returns full fields to the owner",
+ "steps": [
+ {
+ "keyword": "Before",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 97545749
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Given ",
+ "line": 55,
+ "name": "an authenticated user \"alice\"",
+ "match": {
+ "location": "tests/api/user.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 151804583
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 56,
+ "name": "\"alice\" sets their profile to private",
+ "match": {
+ "location": "tests/api/user.steps.ts:89"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 12036875
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "When ",
+ "line": 57,
+ "name": "I get the current user's profile",
+ "match": {
+ "location": "tests/api/user.steps.ts:24"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 3724334
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Then ",
+ "line": 58,
+ "name": "the response status should be 200",
+ "match": {
+ "location": "tests/shared/common.steps.ts:13"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 25916
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 59,
+ "name": "the response body should have property \"emailVerified\"",
+ "match": {
+ "location": "tests/shared/common.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 24208
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 60,
+ "name": "the response body should have property \"isProfilePublic\" equal to false",
+ "match": {
+ "location": "tests/shared/common.steps.ts:73"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 12999
+ }
+ },
+ {
+ "keyword": "After",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 37750
+ },
+ "embeddings": [
+ {
+ "data": "U3RhdHVzOiBQQVNTRUQuIER1cmF0aW9uOjBz",
+ "mime_type": "text/plain"
+ }
+ ]
+ }
+ ],
+ "tags": [],
+ "type": "scenario"
+ },
+ {
+ "description": "",
+ "id": "user-management;public-profile-returns-full-fields-to-other-users",
+ "keyword": "Scenario",
+ "line": 62,
+ "name": "Public profile returns full fields to other users",
+ "steps": [
+ {
+ "keyword": "Before",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 94420749
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Given ",
+ "line": 63,
+ "name": "an authenticated user \"alice\"",
+ "match": {
+ "location": "tests/api/user.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 159459083
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 64,
+ "name": "\"alice\" sets their profile to public",
+ "match": {
+ "location": "tests/api/user.steps.ts:102"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 11530083
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 65,
+ "name": "an authenticated user \"bob\"",
+ "match": {
+ "location": "tests/api/user.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 171745458
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "When ",
+ "line": 66,
+ "name": "\"bob\" gets the profile of \"alice\"",
+ "match": {
+ "location": "tests/api/user.steps.ts:115"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 5636375
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Then ",
+ "line": 67,
+ "name": "the response status should be 200",
+ "match": {
+ "location": "tests/shared/common.steps.ts:13"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 50917
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 68,
+ "name": "the response body should have property \"emailVerified\"",
+ "match": {
+ "location": "tests/shared/common.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 37167
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 69,
+ "name": "the response body should have property \"isProfilePublic\" equal to true",
+ "match": {
+ "location": "tests/shared/common.steps.ts:65"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 73166
+ }
+ },
+ {
+ "keyword": "After",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 38042
+ },
+ "embeddings": [
+ {
+ "data": "U3RhdHVzOiBQQVNTRUQuIER1cmF0aW9uOjBz",
+ "mime_type": "text/plain"
+ }
+ ]
+ }
+ ],
+ "tags": [],
+ "type": "scenario"
+ }
+ ],
+ "id": "user-management",
+ "line": 1,
+ "keyword": "Feature",
+ "name": "User Management",
+ "tags": [],
+ "uri": "tests/api/user.feature"
+ },
+ {
+ "description": " As an operator\n I want background workers to pick up audit events\n So that side-effects fire off the events table asynchronously",
+ "elements": [
+ {
+ "description": "",
+ "id": "background-workers;event-processor-marks-an-unprocessed-event-as-processed",
+ "keyword": "Scenario",
+ "line": 6,
+ "name": "Event processor marks an unprocessed event as processed",
+ "steps": [
+ {
+ "keyword": "Before",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 90885916
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Given ",
+ "line": 7,
+ "name": "an authenticated user \"actor\"",
+ "match": {
+ "location": "tests/api/user.steps.ts:17"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 186264916
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "And ",
+ "line": 8,
+ "name": "an unprocessed event of type \"test.event\" exists for \"actor\"",
+ "match": {
+ "location": "tests/api/workers.steps.ts:23"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 3057082
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "When ",
+ "line": 9,
+ "name": "the event processor queue is triggered",
+ "match": {
+ "location": "tests/api/workers.steps.ts:41"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 32558208
+ }
+ },
+ {
+ "arguments": [],
+ "keyword": "Then ",
+ "line": 10,
+ "name": "the event should be marked processed within 10 seconds",
+ "match": {
+ "location": "tests/api/workers.steps.ts:51"
+ },
+ "result": {
+ "status": "passed",
+ "duration": 768986291
+ }
+ },
+ {
+ "keyword": "After",
+ "hidden": true,
+ "result": {
+ "status": "passed",
+ "duration": 219000
+ },
+ "embeddings": [
+ {
+ "data": "U3RhdHVzOiBQQVNTRUQuIER1cmF0aW9uOjBz",
+ "mime_type": "text/plain"
+ }
+ ]
+ }
+ ],
+ "tags": [],
+ "type": "scenario"
+ }
+ ],
+ "id": "background-workers",
+ "line": 1,
+ "keyword": "Feature",
+ "name": "Background workers",
+ "tags": [],
+ "uri": "tests/api/workers.feature"
}
]
\ No newline at end of file
diff --git a/apps/modeling-commons-backend/reports/index.html b/apps/modeling-commons-backend/reports/index.html
index dfee003f..c0a3920a 100644
--- a/apps/modeling-commons-backend/reports/index.html
+++ b/apps/modeling-commons-backend/reports/index.html
@@ -46,7 +46,7 @@
diff --git a/apps/modeling-commons-frontend/app/components/user/UserSelectMenu.vue b/apps/modeling-commons-frontend/app/components/user/UserSelectMenu.vue
index ec645ade..0ab50524 100644
--- a/apps/modeling-commons-frontend/app/components/user/UserSelectMenu.vue
+++ b/apps/modeling-commons-frontend/app/components/user/UserSelectMenu.vue
@@ -7,7 +7,15 @@
:items="userMenuItems"
virtualize
:loading="loading"
+ clear
>
+
+
+
+ Clear selected user
+
+
+
(() => {
}
return [...props.users.map(toUserSelectMenuItem)];
});
-const selectedUser = defineModel({
- type: Object as () => UserSelectMenuItem,
- default: null,
+const selectedUser = defineModel({
+ type: Object as () => UserSelectMenuItem | undefined,
+ default: undefined,
});
const searchTerm = defineModel("search-term", { type: String, default: "" });
const selectMenu = useTemplateRef("selectMenu");
+function clearSelection() {
+ selectedUser.value = undefined;
+ searchTerm.value = "";
+ selectMenu.value?.triggerRef.click();
+}
+
onMounted(() => {
useInfiniteScroll(
// @ts-expect-error -- need to update @nuxt/ui for this to work
From 4554b8c42d86a327838f7bc074a4893d65d92dd9 Mon Sep 17 00:00:00 2001
From: Omar Ibrahim
Date: Thu, 18 Jun 2026 12:25:23 -0500
Subject: [PATCH 042/101] Minor(modeling-commons): Remove UUID prefix from
filenames
---
.../src/shared/storage/utils.spec.ts | 16 ++++++++--------
.../src/shared/storage/utils.ts | 4 ++--
2 files changed, 10 insertions(+), 10 deletions(-)
diff --git a/apps/modeling-commons-backend/src/shared/storage/utils.spec.ts b/apps/modeling-commons-backend/src/shared/storage/utils.spec.ts
index bbde5243..f4e0335f 100644
--- a/apps/modeling-commons-backend/src/shared/storage/utils.spec.ts
+++ b/apps/modeling-commons-backend/src/shared/storage/utils.spec.ts
@@ -89,15 +89,15 @@ describe('createStorageKey', () => {
test('includes normalized path, UTC date segments, id prefix, and sanitized filename', () => {
const key = createStorageKey('my model.png', 'uploads/models');
- expect(key).toMatch(/^uploads\/models\/2026\/04\/17\/[a-f0-9]{8}-my_model\.png$/);
+ expect(key).toMatch(/^uploads\/models\/2026\/04\/17\/[a-f0-9]{8}\/my_model\.png$/);
});
test('normalizes leading and trailing slashes in path', () => {
const key1 = createStorageKey('file.txt', '/uploads/models/');
const key2 = createStorageKey('file.txt', '///uploads/models///');
- expect(key1).toMatch(/^uploads\/models\/2026\/04\/17\/[a-f0-9]{8}-file\.txt$/);
- expect(key2).toMatch(/^uploads\/models\/2026\/04\/17\/[a-f0-9]{8}-file\.txt$/);
+ expect(key1).toMatch(/^uploads\/models\/2026\/04\/17\/[a-f0-9]{8}\/file\.txt$/);
+ expect(key2).toMatch(/^uploads\/models\/2026\/04\/17\/[a-f0-9]{8}\/file\.txt$/);
expect(key1).not.toContain('//');
expect(key2).not.toContain('//');
});
@@ -105,15 +105,15 @@ describe('createStorageKey', () => {
test('omits the leading path segment when path is empty', () => {
const key = createStorageKey('file.txt', '');
- expect(key).toMatch(/^2026\/04\/17\/[a-f0-9]{8}-file\.txt$/);
+ expect(key).toMatch(/^2026\/04\/17\/[a-f0-9]{8}\/file\.txt$/);
});
test('sanitizes the filename portion', () => {
const key = createStorageKey('../../etc/passwd', 'uploads');
- expect(key).toMatch(/^uploads\/2026\/04\/17\/[a-f0-9]{8}-/);
+ expect(key).toMatch(/^uploads\/2026\/04\/17\/[a-f0-9]{8}\//);
- const filenamePart = key.replace(/^uploads\/2026\/04\/17\/[a-f0-9]{8}-/, '');
+ const filenamePart = key.replace(/^uploads\/2026\/04\/17\/[a-f0-9]{8}\//, '');
expect(filenamePart).toBe('__.._etc_passwd');
expect(filenamePart).toMatch(/^[A-Za-z0-9._-]+$/);
});
@@ -128,11 +128,11 @@ describe('createStorageKey', () => {
test('produces a valid key shape even with malformed inputs', () => {
const key = createStorageKey('..\n..\npasswd', '///uploads//');
- expect(key).toMatch(/^uploads\/2026\/04\/17\/[a-f0-9]{8}-/);
+ expect(key).toMatch(/^uploads\/2026\/04\/17\/[a-f0-9]{8}\//);
expect(key).not.toContain('//');
expect(key).not.toContain('\\');
- const filenamePart = key.replace(/^uploads\/2026\/04\/17\/[a-f0-9]{8}-/, '');
+ const filenamePart = key.replace(/^uploads\/2026\/04\/17\/[a-f0-9]{8}\//, '');
expect(filenamePart).toMatch(/^[A-Za-z0-9._-]+$/);
});
});
diff --git a/apps/modeling-commons-backend/src/shared/storage/utils.ts b/apps/modeling-commons-backend/src/shared/storage/utils.ts
index 482e7e49..6cafde40 100644
--- a/apps/modeling-commons-backend/src/shared/storage/utils.ts
+++ b/apps/modeling-commons-backend/src/shared/storage/utils.ts
@@ -46,8 +46,8 @@ export function createStorageKey(filename: string, path: string): string {
const id = randomUUID().substring(0, 8);
if (normalizedPath.length === 0) {
- return `${timepath}/${id}-${sanitizedFilename}`;
+ return `${timepath}/${id}/${sanitizedFilename}`;
}
- return `${normalizedPath}/${timepath}/${id}-${sanitizedFilename}`;
+ return `${normalizedPath}/${timepath}/${id}/${sanitizedFilename}`;
}
From 4fc969b28684fd310304acf27e6a8064ebd1b757 Mon Sep 17 00:00:00 2001
From: Omar Ibrahim
Date: Thu, 18 Jun 2026 12:28:12 -0500
Subject: [PATCH 043/101] Minor(modeling-commons): Use random avatar image
names for privacy
---
apps/modeling-commons-backend/src/modules/file/file.route.ts | 5 ++++-
1 file changed, 4 insertions(+), 1 deletion(-)
diff --git a/apps/modeling-commons-backend/src/modules/file/file.route.ts b/apps/modeling-commons-backend/src/modules/file/file.route.ts
index 9fd27cdb..d8d9e7bb 100644
--- a/apps/modeling-commons-backend/src/modules/file/file.route.ts
+++ b/apps/modeling-commons-backend/src/modules/file/file.route.ts
@@ -4,6 +4,7 @@ import { resolveFile } from '#src/shared/hooks/resolve-file.ts';
import type { FastifyInstance } from 'fastify';
import { Type } from 'typebox';
import { FileTooLargeError } from './domain/file.errors.ts';
+import { randomUUID } from 'node:crypto';
const MAX_AVATAR_BYTES = rules.avatar.maxFileSize;
@@ -40,12 +41,14 @@ export default async function fileRoutes(fastify: FastifyInstance) {
],
},
async (request, reply) => {
- const { filename, mimetype, buffer } = request.uploadedFile;
+ const { mimetype, buffer } = request.uploadedFile;
if (buffer.length > MAX_AVATAR_BYTES) {
throw new FileTooLargeError(buffer.length, MAX_AVATAR_BYTES);
}
+ const filename = `${randomUUID()}-ua`;
+
const key = await fileService.upload({
filename,
buffer,
From 1b880f5549a7d8fb4527bb7b28dce1df2a8cc365 Mon Sep 17 00:00:00 2001
From: Omar Ibrahim
Date: Thu, 18 Jun 2026 12:38:27 -0500
Subject: [PATCH 044/101] Bugfix(modeling-commons): Disable scroll lock for
action menu
---
.../app/components/model/detail/ModelHeader.vue | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/apps/modeling-commons-frontend/app/components/model/detail/ModelHeader.vue b/apps/modeling-commons-frontend/app/components/model/detail/ModelHeader.vue
index 2f7412ca..f86fb45b 100644
--- a/apps/modeling-commons-frontend/app/components/model/detail/ModelHeader.vue
+++ b/apps/modeling-commons-frontend/app/components/model/detail/ModelHeader.vue
@@ -31,7 +31,7 @@
>
Download
-
+
From 4a9e39a70238018f65f6ef193bd8f50b36e5161c Mon Sep 17 00:00:00 2001
From: Omar Ibrahim
Date: Thu, 18 Jun 2026 12:51:05 -0500
Subject: [PATCH 045/101] Minor(modeling-commons): Add "My Models" to Navbar
---
.../app/components/layout/ClientNavbar.vue | 5 +++++
1 file changed, 5 insertions(+)
diff --git a/apps/modeling-commons-frontend/app/components/layout/ClientNavbar.vue b/apps/modeling-commons-frontend/app/components/layout/ClientNavbar.vue
index fd68ca18..b7947a4e 100644
--- a/apps/modeling-commons-frontend/app/components/layout/ClientNavbar.vue
+++ b/apps/modeling-commons-frontend/app/components/layout/ClientNavbar.vue
@@ -167,6 +167,11 @@ const userDropdownItems = computed>>(() => {
icon: "i-lucide-user",
href: "/profile",
},
+ {
+ label: "My Models",
+ icon: "i-lucide-box",
+ href: `/users/${user.value.id}/models`,
+ },
],
[
{
From ccd1df0d5e3f6a272a31e1f32b511a50be4eb73b Mon Sep 17 00:00:00 2001
From: Omar Ibrahim
Date: Thu, 18 Jun 2026 12:58:21 -0500
Subject: [PATCH 046/101] Minor(modeling-commons): Remove unused 'Skip for Now'
from onboarding
---
apps/modeling-commons-frontend/app/pages/onboarding.vue | 8 --------
1 file changed, 8 deletions(-)
diff --git a/apps/modeling-commons-frontend/app/pages/onboarding.vue b/apps/modeling-commons-frontend/app/pages/onboarding.vue
index 70449d8d..c28ddb75 100644
--- a/apps/modeling-commons-frontend/app/pages/onboarding.vue
+++ b/apps/modeling-commons-frontend/app/pages/onboarding.vue
@@ -57,14 +57,6 @@
>
Get started
-
- Skip for now
-
From 647680f55563574c7ae1d04482322b269ee7cac8 Mon Sep 17 00:00:00 2001
From: Omar Ibrahim
Date: Thu, 18 Jun 2026 13:00:54 -0500
Subject: [PATCH 047/101] Feature(modeling-commons): Add back to top component
---
.../app/components/shared/BackToTop.vue | 25 +++++++++++++++++++
.../app/composables/shared/useBackToTop.ts | 22 ++++++++++++++++
2 files changed, 47 insertions(+)
create mode 100644 apps/modeling-commons-frontend/app/components/shared/BackToTop.vue
create mode 100644 apps/modeling-commons-frontend/app/composables/shared/useBackToTop.ts
diff --git a/apps/modeling-commons-frontend/app/components/shared/BackToTop.vue b/apps/modeling-commons-frontend/app/components/shared/BackToTop.vue
new file mode 100644
index 00000000..9e90368b
--- /dev/null
+++ b/apps/modeling-commons-frontend/app/components/shared/BackToTop.vue
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
diff --git a/apps/modeling-commons-frontend/app/composables/shared/useBackToTop.ts b/apps/modeling-commons-frontend/app/composables/shared/useBackToTop.ts
new file mode 100644
index 00000000..944ad50a
--- /dev/null
+++ b/apps/modeling-commons-frontend/app/composables/shared/useBackToTop.ts
@@ -0,0 +1,22 @@
+export function useBackToTop(threshold = 600) {
+ const show = ref(false);
+
+ const update = () => {
+ show.value = window.scrollY > threshold;
+ };
+
+ const scrollToTop = () => {
+ window.scrollTo({ top: 0, behavior: "smooth" });
+ };
+
+ onMounted(() => {
+ window.addEventListener("scroll", update, { passive: true });
+ update();
+ });
+
+ onBeforeUnmount(() => {
+ window.removeEventListener("scroll", update);
+ });
+
+ return { show, scrollToTop };
+}
From 8749e6f751e99aeecc3997234491f1b47ddc816c Mon Sep 17 00:00:00 2001
From: Omar Ibrahim
Date: Thu, 18 Jun 2026 13:01:06 -0500
Subject: [PATCH 048/101] Feature(modeling-commons): Add back to top to model
lists
---
.../app/components/model/list/ModelsListing.vue | 4 ++++
apps/modeling-commons-frontend/app/pages/models/index.vue | 4 ++++
2 files changed, 8 insertions(+)
diff --git a/apps/modeling-commons-frontend/app/components/model/list/ModelsListing.vue b/apps/modeling-commons-frontend/app/components/model/list/ModelsListing.vue
index 58ce3140..2c5c0e4a 100644
--- a/apps/modeling-commons-frontend/app/components/model/list/ModelsListing.vue
+++ b/apps/modeling-commons-frontend/app/components/model/list/ModelsListing.vue
@@ -20,6 +20,8 @@
Showing {{ rows.length }} of {{ totalCount }} models
+
+
@@ -39,6 +41,8 @@ const filters = computed(() => props.filters);
const orientation = ref<"horizontal" | "vertical">("horizontal");
+const { show: showBackToTop, scrollToTop } = useBackToTop();
+
const { rows, totalCount, pending, error, hasMore, refresh, nextPage } = useForumModels({
filters,
});
diff --git a/apps/modeling-commons-frontend/app/pages/models/index.vue b/apps/modeling-commons-frontend/app/pages/models/index.vue
index 12f17f63..6ecdd100 100644
--- a/apps/modeling-commons-frontend/app/pages/models/index.vue
+++ b/apps/modeling-commons-frontend/app/pages/models/index.vue
@@ -156,6 +156,8 @@
Showing {{ rows.length }} of {{ totalCount }} models
+
+
@@ -203,6 +205,8 @@ const highlightFlags = ref({
),
});
+const { show: showBackToTop, scrollToTop } = useBackToTop();
+
const author = reactive(useUserFilter(filters, setFilter));
const tags = reactive(useTagsFilter(filters, setFilter));
const version = reactive(useNetlogoVersionsFilter(filters, setFilter));
From 8500baa4caadfccf9992464e4a4f1842c0abb3ac Mon Sep 17 00:00:00 2001
From: Omar Ibrahim
Date: Thu, 18 Jun 2026 13:34:48 -0500
Subject: [PATCH 049/101] Bugfix(modeling-commons): remove passkey from plugin
types on backend
---
apps/modeling-commons-backend/src/lib/auth.ts | 8 +++++++-
1 file changed, 7 insertions(+), 1 deletion(-)
diff --git a/apps/modeling-commons-backend/src/lib/auth.ts b/apps/modeling-commons-backend/src/lib/auth.ts
index 90f2e1f2..5599292b 100644
--- a/apps/modeling-commons-backend/src/lib/auth.ts
+++ b/apps/modeling-commons-backend/src/lib/auth.ts
@@ -8,6 +8,7 @@ import rules from '#src/config/rules.ts';
import transporter, { mailDomain } from './mail.ts';
import { prisma } from './prisma.ts';
+const corePlugins = [openAPI({ disableDefaultReference: false }), admin()];
export const auth = betterAuth({
appName: env.product.name,
baseURL: env.auth.url,
@@ -120,7 +121,12 @@ export const auth = betterAuth({
},
},
- plugins: [openAPI({ disableDefaultReference: false }), admin(), passkey()],
+ // The passkey plugin breaks types on frontend and backend
+ // The solution here is easier since we don't rely on the
+ // passkey API directly, so we can just exclude it from the inferred
+ // plugin types.
+ // -- Omar Ibrahim, Jun 18 26
+ plugins: [openAPI({ disableDefaultReference: false }), admin(), passkey()] as typeof corePlugins,
});
export type Session = typeof auth.$Infer.Session;
From 32d6e7c7982e0848b3d67db4a3e11af0a1b9de29 Mon Sep 17 00:00:00 2001
From: Omar Ibrahim
Date: Thu, 18 Jun 2026 13:35:27 -0500
Subject: [PATCH 050/101] Bugfix(modeling-commons): treat legacy sign up as
password reset
---
.../src/server/plugins/auth.ts | 23 +++++++-
.../tests/api/auth.feature | 22 ++++++++
.../tests/api/auth.steps.ts | 52 +++++++++++++++++++
3 files changed, 96 insertions(+), 1 deletion(-)
diff --git a/apps/modeling-commons-backend/src/server/plugins/auth.ts b/apps/modeling-commons-backend/src/server/plugins/auth.ts
index e1c807be..247156f3 100644
--- a/apps/modeling-commons-backend/src/server/plugins/auth.ts
+++ b/apps/modeling-commons-backend/src/server/plugins/auth.ts
@@ -112,7 +112,28 @@ async function authPlugin(fastify: FastifyInstance) {
...(request.body ? { body: JSON.stringify(request.body) } : {}),
});
- const response = await auth.handler(req);
+ const signUpEmailPath = `${auth.options.basePath}/sign-up/email`;
+
+ let response: Response;
+ if (url.pathname === signUpEmailPath && request.method === 'POST') {
+ const { email } = request.body as { email?: string };
+ const isLegacyUserWithNoAccount = email
+ ? await prisma.user.findFirst({
+ where: {
+ email,
+ legacyId: { not: null },
+ accounts: { none: {} },
+ },
+ })
+ : null;
+
+ response =
+ isLegacyUserWithNoAccount && email
+ ? await auth.api.requestPasswordReset({ asResponse: true, body: { email } })
+ : await auth.handler(req);
+ } else {
+ response = await auth.handler(req);
+ }
reply.status(response.status);
response.headers.forEach((value, key) => reply.header(key, value));
diff --git a/apps/modeling-commons-backend/tests/api/auth.feature b/apps/modeling-commons-backend/tests/api/auth.feature
index f32a9cb0..2f82eaf9 100644
--- a/apps/modeling-commons-backend/tests/api/auth.feature
+++ b/apps/modeling-commons-backend/tests/api/auth.feature
@@ -22,3 +22,25 @@ Feature: Authentication
Given a verified user with email "carol@test.local" and password "StrongPass1!"
When I sign in with email "carol@test.local" and password "WrongPass1!"
Then the signin should be rejected
+
+ @legacy-signup
+ Scenario: Legacy user without an account gets a password reset instead of a sign-up
+ Given a legacy user with email "legacy@test.local" and no linked account
+ When I sign up with name "Legacy" email "legacy@test.local" and password "StrongPass1!"
+ Then the response status should be 200
+ And the response body should not have property "user"
+ And the user "legacy@test.local" should still have no linked account
+
+ @legacy-signup
+ Scenario: Existing user with an account is handled normally on sign-up
+ Given a verified user with email "existing@test.local" and password "StrongPass1!"
+ When I sign up with name "Existing" email "existing@test.local" and password "StrongPass1!"
+ Then the response status should be 200
+ And the response body should have property "user"
+
+ @legacy-signup
+ Scenario: New user with no account is signed up normally
+ When I sign up with name "Newbie" email "newbie@test.local" and password "StrongPass1!"
+ Then the response status should be 200
+ And the response body should have property "user"
+ And the user "newbie@test.local" should have a linked account
diff --git a/apps/modeling-commons-backend/tests/api/auth.steps.ts b/apps/modeling-commons-backend/tests/api/auth.steps.ts
index 5f9fe495..633c5dcf 100644
--- a/apps/modeling-commons-backend/tests/api/auth.steps.ts
+++ b/apps/modeling-commons-backend/tests/api/auth.steps.ts
@@ -3,6 +3,20 @@ import { Given, When, Then } from '@cucumber/cucumber';
import type { ICustomWorld } from '../support/custom-world.ts';
import { signUp } from '../support/auth-helper.ts';
+interface PrismaCradle {
+ prisma: {
+ user: {
+ create: (args: { data: Record }) => Promise<{ id: string }>;
+ findFirst: (args: {
+ where: Record;
+ include?: Record;
+ }) => Promise<{ id: string; accounts: unknown[] } | null>;
+ };
+ };
+}
+
+let legacyIdCounter = 0;
+
Given(
'a registered user with email {string}',
async function (this: ICustomWorld, email: string) {
@@ -10,6 +24,21 @@ Given(
},
);
+Given(
+ 'a legacy user with email {string} and no linked account',
+ async function (this: ICustomWorld, email: string) {
+ const { prisma } = this.server.diContainer.cradle as unknown as PrismaCradle;
+ await prisma.user.create({
+ data: {
+ name: 'Legacy User',
+ email,
+ emailVerified: true,
+ legacyId: ++legacyIdCounter,
+ },
+ });
+ },
+);
+
Given(
'a verified user with email {string} and password {string}',
async function (this: ICustomWorld, email: string, password: string) {
@@ -64,3 +93,26 @@ Then(
assert.ok(code >= 400, `Expected error status code, got ${code}`);
},
);
+
+async function findAccountCount(this: ICustomWorld, email: string): Promise {
+ const { prisma } = this.server.diContainer.cradle as unknown as PrismaCradle;
+ const user = await prisma.user.findFirst({ where: { email }, include: { accounts: true } });
+ assert.ok(user, `Expected a user with email "${email}"`);
+ return user.accounts.length;
+}
+
+Then(
+ 'the user {string} should still have no linked account',
+ async function (this: ICustomWorld, email: string) {
+ const count = await findAccountCount.call(this, email);
+ assert.strictEqual(count, 0, `Expected no linked account, found ${count}`);
+ },
+);
+
+Then(
+ 'the user {string} should have a linked account',
+ async function (this: ICustomWorld, email: string) {
+ const count = await findAccountCount.call(this, email);
+ assert.ok(count > 0, 'Expected a linked account, found none');
+ },
+);
From 125fa46052fe03a08a143af650897bbd47057f97 Mon Sep 17 00:00:00 2001
From: Omar Ibrahim
Date: Thu, 18 Jun 2026 13:56:30 -0500
Subject: [PATCH 051/101] ci(modeling-commons): add more comprehensive seed
data
---
apps/modeling-commons-backend/prisma/seed.ts | 778 +-----------------
.../prisma/seed/assets.ts | 194 +++++
.../prisma/seed/builders.ts | 344 --------
.../prisma/seed/files.ts | 68 --
.../prisma/seed/id.ts | 51 +-
.../prisma/seed/index.ts | 341 +-------
.../prisma/seed/loaders.ts | 480 +++++++++++
.../prisma/seed/manifest/drafts.ts | 45 +
.../prisma/seed/manifest/index.ts | 5 +
.../prisma/seed/manifest/models.ts | 558 +++++++++++++
.../prisma/seed/manifest/tags.ts | 28 +
.../prisma/seed/manifest/types.ts | 126 +++
.../prisma/seed/manifest/users.ts | 178 ++++
.../prisma/seed/providers.ts | 2 +-
14 files changed, 1683 insertions(+), 1515 deletions(-)
create mode 100644 apps/modeling-commons-backend/prisma/seed/assets.ts
delete mode 100644 apps/modeling-commons-backend/prisma/seed/builders.ts
delete mode 100644 apps/modeling-commons-backend/prisma/seed/files.ts
create mode 100644 apps/modeling-commons-backend/prisma/seed/loaders.ts
create mode 100644 apps/modeling-commons-backend/prisma/seed/manifest/drafts.ts
create mode 100644 apps/modeling-commons-backend/prisma/seed/manifest/index.ts
create mode 100644 apps/modeling-commons-backend/prisma/seed/manifest/models.ts
create mode 100644 apps/modeling-commons-backend/prisma/seed/manifest/tags.ts
create mode 100644 apps/modeling-commons-backend/prisma/seed/manifest/types.ts
create mode 100644 apps/modeling-commons-backend/prisma/seed/manifest/users.ts
diff --git a/apps/modeling-commons-backend/prisma/seed.ts b/apps/modeling-commons-backend/prisma/seed.ts
index d41c92b0..e5f5d124 100644
--- a/apps/modeling-commons-backend/prisma/seed.ts
+++ b/apps/modeling-commons-backend/prisma/seed.ts
@@ -1,775 +1,3 @@
-import 'dotenv/config';
-import { PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
-
-import { PrismaPg } from '@prisma/adapter-pg';
-import { PrismaClient } from '../generated/prisma/client.js';
-
-import fs from 'node:fs';
-import path from 'node:path';
-
-const adapter = new PrismaPg({ connectionString: process.env['DATABASE_URL']! });
-const prisma = new PrismaClient({ adapter });
-const storage = new S3Client({
- region: process.env['STORE_REGION'],
-
- credentials: {
- accessKeyId: process.env['STORE_ACCESS_KEY']!,
- secretAccessKey: process.env['STORE_SECRET_KEY']!,
- },
-
- endpoint: process.env['STORE_ENDPOINT'],
- forcePathStyle: true,
-});
-const bucket = { Name: process.env['STORE_BUCKET']! };
-const seedFilesPath = path.join(import.meta.dirname, 'seed-files');
-
-const getNonRandomUUID = (() => {
- const counter: Record = {};
-
- return (prefix: string, from: number = 0) => {
- if (!counter[prefix]) {
- counter[prefix] = from;
- }
- if (prefix.length != 8) {
- throw new Error(`Prefix must be 8 characters long: ${prefix}`);
- }
- const id = `${prefix}-0000-4000-a000-${counter[prefix].toString().padStart(12, '0')}`;
- counter[prefix]++;
- return id;
- };
-})();
-
-const ids = {
- // Users
- alice: '10000000-0000-4000-a000-000000000001',
- bob: '10000000-0000-4000-a000-000000000002',
- carol: '10000000-0000-4000-a000-000000000003',
- dave: '10000000-0000-4000-a000-000000000004',
- admin: '10000000-0000-4000-a000-000000000099',
-
- // Models
- wolfSheep: '20000000-0000-4000-a000-000000000001',
- fireSpread: '20000000-0000-4000-a000-000000000002',
- antForaging: '20000000-0000-4000-a000-000000000003',
- virusNetwork: '20000000-0000-4000-a000-000000000004',
- wolfSheepFork: '20000000-0000-4000-a000-000000000005',
- trafficBasic: '20000000-0000-4000-a000-000000000006',
- traffic2Lanes: '20000000-0000-4000-a000-000000000007',
-
- // Tags
- tagEcology: '50000000-0000-4000-a000-000000000001',
- tagPredatorPrey: '50000000-0000-4000-a000-000000000002',
- tagFire: '50000000-0000-4000-a000-000000000003',
- tagEmergence: '50000000-0000-4000-a000-000000000004',
- tagBiology: '50000000-0000-4000-a000-000000000005',
- tagNetwork: '50000000-0000-4000-a000-000000000006',
- tagEpidemiology: '50000000-0000-4000-a000-000000000007',
- tagSwarmIntelligence: '50000000-0000-4000-a000-000000000008',
-
- // Permissions
- permBobReadWolfSheep: '60000000-0000-4000-a000-000000000001',
- permCarolWriteFireSpread: '60000000-0000-4000-a000-000000000002',
- permPublicReadAntForaging: '60000000-0000-4000-a000-000000000003',
-
- // Events
- event1: '70000000-0000-4000-a000-000000000001',
- event2: '70000000-0000-4000-a000-000000000002',
- event3: '70000000-0000-4000-a000-000000000003',
- event4: '70000000-0000-4000-a000-000000000004',
- event5: '70000000-0000-4000-a000-000000000005',
-
- // Accounts (Better Auth credential accounts)
- accountAlice: '80000000-0000-4000-a000-000000000001',
- accountBob: '80000000-0000-4000-a000-000000000002',
- accountCarol: '80000000-0000-4000-a000-000000000003',
- accountDave: '80000000-0000-4000-a000-000000000004',
- accountAdmin: '80000000-0000-4000-a000-000000000099',
-
- // Sessions
- sessionAlice: '90000000-0000-4000-a000-000000000001',
- sessionBob: '90000000-0000-4000-a000-000000000002',
-
- // Additional files
- additionalFile1: 'a0000000-0000-4000-a000-000000000001',
- additionalFile2: 'a0000000-0000-4000-a000-000000000002',
-
- // Model version files
- mvFile1: 'b0000000-0000-4000-a000-000000000001',
- mvFile2: 'b0000000-0000-4000-a000-000000000002',
-} as const;
-
-// Helpers
-
-function fakeNlogox(title: string): Buffer {
- // Minimal placeholder representing an nlogox XML file
- return Buffer.from(
- `\n${title}`,
- 'utf-8',
- );
-}
-
-function fakeCsv(): Buffer {
- return Buffer.from('tick,wolves,sheep\n0,50,100\n1,48,105\n2,45,112\n', 'utf-8');
-}
-
-function fakeReadme(): Buffer {
- return Buffer.from('# Wolf Sheep Predation\n\nA classic predator-prey model.', 'utf-8');
-}
-
-function readInfoTab(xmlString: string): string | undefined {
- return `
- # Info Tab Content
- This is a placeholder for the info tab content extracted from the nlogox file.
- In a real implementation, you would parse the XML and extract the relevant section.
- `;
-}
-
-function readNlogox(
- filename: string,
- previewImgName: string,
-): {
- key: string;
- filename: string;
- blob: Buffer;
- contentType: string;
- previewImage: {
- key: string;
- filename: string;
- blob: Buffer;
- contentType: string;
- };
- sizeBytes: bigint;
- infoTab: string | undefined;
-} {
- const filepath = path.join(seedFilesPath, filename);
- const stats = fs.statSync(filepath);
- const content = fs.readFileSync(filepath, 'utf-8');
- const infoTab = readInfoTab(content);
-
- const previewImagePath = path.join(seedFilesPath, previewImgName);
- const imageBlob = fs.readFileSync(previewImagePath);
- const previewImageExt = path.extname(previewImgName).toLowerCase();
- const mimes: Record = {
- '.png': 'image/png',
- '.jpg': 'image/jpeg',
- '.jpeg': 'image/jpeg',
- '.gif': 'image/gif',
- '.bmp': 'image/bmp',
- '.webp': 'image/webp',
- };
- const previewImageContentType = mimes[previewImageExt] || 'application/octet-stream';
-
- return {
- key: 'uploads/models/' + filename,
- filename,
- blob: Buffer.from(content, 'utf-8'),
- contentType: 'application/xml',
- previewImage: {
- key: `files/public/preview-images/seed/${previewImgName}`,
- filename: previewImgName,
- blob: imageBlob,
- contentType: previewImageContentType,
- },
- sizeBytes: BigInt(stats.size),
- infoTab,
- };
-}
-
-// Seed
-async function main() {
- console.log('Seeding database...');
-
- // 1. Users
- const users = [
- {
- id: ids.alice,
- name: 'Alice Bob',
- email: 'alice@example.com',
- emailVerified: true,
- systemRole: 'user' as const,
- userKind: 'researcher' as const,
- isProfilePublic: true,
- },
- {
- id: ids.bob,
- name: 'Bob Rand',
- email: 'bob@example.com',
- emailVerified: true,
- systemRole: 'user' as const,
- userKind: 'teacher' as const,
- isProfilePublic: true,
- },
- {
- id: ids.carol,
- name: 'Carol Tisue',
- email: 'carol@example.com',
- emailVerified: true,
- systemRole: 'moderator' as const,
- userKind: 'researcher' as const,
- isProfilePublic: false,
- },
- {
- id: ids.dave,
- name: 'Dave Student',
- email: 'dave@example.com',
- emailVerified: false,
- systemRole: 'user' as const,
- userKind: 'student' as const,
- isProfilePublic: false,
- },
- {
- id: ids.admin,
- name: 'Admin',
- email: 'admin@example.com',
- emailVerified: true,
- systemRole: 'admin' as const,
- userKind: 'other' as const,
- isProfilePublic: false,
- },
- ];
-
- for (const u of users) {
- await prisma.user.upsert({
- where: { id: u.id },
- update: {},
- create: u,
- });
- }
- console.log(` ✓ ${users.length} users`);
-
- // 2. Accounts (Better Auth credential type)
- const accounts = [
- { id: ids.accountAlice, userId: ids.alice, accountId: ids.alice, providerId: ids.alice },
- { id: ids.accountBob, userId: ids.bob, accountId: ids.bob, providerId: ids.bob },
- { id: ids.accountCarol, userId: ids.carol, accountId: ids.carol, providerId: ids.carol },
- { id: ids.accountDave, userId: ids.dave, accountId: ids.dave, providerId: ids.dave },
- { id: ids.accountAdmin, userId: ids.admin, accountId: ids.admin, providerId: ids.admin },
- ];
-
- for (const a of accounts) {
- await prisma.account.upsert({
- where: { id: a.id },
- update: {},
- create: a,
- });
- }
- console.log(` ✓ ${accounts.length} accounts`);
-
- // 3. Sessions (active dev sessions for Alice & Bob)
- const thirtyDaysFromNow = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
- const sessions = [
- {
- id: ids.sessionAlice,
- userId: ids.alice,
- token: 'dev-session-token-alice-000001',
- expiresAt: thirtyDaysFromNow,
- ipAddress: '127.0.0.1',
- userAgent: 'Mozilla/5.0 (seed)',
- },
- {
- id: ids.sessionBob,
- userId: ids.bob,
- token: 'dev-session-token-bob-000002',
- expiresAt: thirtyDaysFromNow,
- ipAddress: '127.0.0.1',
- userAgent: 'Mozilla/5.0 (seed)',
- },
- ];
-
- for (const s of sessions) {
- await prisma.session.upsert({
- where: { id: s.id },
- update: {},
- create: s,
- });
- }
- console.log(` ✓ ${sessions.length} sessions`);
-
- // 4. Tags
- const tags = [
- { id: ids.tagEcology, name: 'ecology' },
- { id: ids.tagPredatorPrey, name: 'predator-prey' },
- { id: ids.tagFire, name: 'fire' },
- { id: ids.tagEmergence, name: 'emergence' },
- { id: ids.tagBiology, name: 'biology' },
- { id: ids.tagNetwork, name: 'network' },
- { id: ids.tagEpidemiology, name: 'epidemiology' },
- { id: ids.tagSwarmIntelligence, name: 'swarm-intelligence' },
- ];
-
- for (const t of tags) {
- await prisma.tag.upsert({
- where: { id: t.id },
- update: {},
- create: t,
- });
- }
- console.log(` ✓ ${tags.length} tags`);
-
- // 5. Files (nlogox files + supplementary)
- const keys = {
- virusNetworkNlogox: 'uploads/models/virus-network.nlogox',
- wolfSheepReadme: 'uploads/models/wolf-sheep-readme.md',
- wolfSheepData: 'uploads/models/wolf-sheep-data.csv',
- fireSpreadCsv: 'uploads/models/fire-spread-data.csv',
- };
- const seedNlogoxFiles = {
- wolfSheepNlogox1: readNlogox('wolf-sheep-predation.nlogox', 'wolf-sheep-preview.png'),
- wolfSheepNlogox2: readNlogox('wolf-sheep-predation-v2.nlogox', 'wolf-sheep-preview.png'),
- wolfSheepNlogoxFork: readNlogox('wolf-sheep-predation-fork.nlogox', 'wolf-sheep-preview.png'),
- trafficGridNlogox: readNlogox('traffic-grid.nlogox', 'traffic-grid-preview.png'),
- trafficBasicNlogox: readNlogox('traffic-basic.nlogox', 'traffic-grid-preview.png'),
- traffic2LanesNlogox: readNlogox('traffic-2-lanes.nlogox', 'traffic-2-lanes-preview.png'),
- fire: readNlogox('fire.nlogox', 'fire-preview.png'),
- ants: readNlogox('ants.nlogox', 'ants-preview.png'),
- };
-
- const files = [
- ...Object.values(seedNlogoxFiles).map((f) => ({
- key: f.key,
- filename: `${f.filename}`,
- contentType: f.contentType,
- sizeBytes: f.sizeBytes,
- blob: f.blob,
- })),
- {
- key: keys.virusNetworkNlogox,
- filename: 'virus-network.nlogox',
- contentType: 'application/xml',
- sizeBytes: BigInt(390) as bigint,
- blob: fakeNlogox('Virus on a Network'),
- },
- {
- key: keys.wolfSheepReadme,
- filename: 'README.md',
- contentType: 'text/markdown',
- sizeBytes: BigInt(58) as bigint,
- blob: fakeReadme(),
- },
- {
- key: keys.wolfSheepData,
- filename: 'initial-data.csv',
- contentType: 'text/csv',
- sizeBytes: BigInt(52) as bigint,
- blob: fakeCsv(),
- },
- {
- key: keys.fireSpreadCsv,
- filename: 'burn-results.csv',
- contentType: 'text/csv',
- sizeBytes: BigInt(52) as bigint,
- blob: fakeCsv(),
- },
- ];
-
- for (const f of files) {
- await storage.send(
- new PutObjectCommand({
- Bucket: bucket.Name,
- Key: f.key,
- Body: f.blob,
- ContentType: f.contentType,
- Metadata: {
- filename: f.filename,
- createdAt: new Date().toISOString(),
- },
- }),
- );
- }
- console.log(` ✓ ${files.length} files`);
-
- const previewImages = Array.from(
- new Map(
- Object.values(seedNlogoxFiles).map((f) => [f.previewImage.key, f.previewImage]),
- ).values(),
- );
- for (const img of previewImages) {
- await storage.send(
- new PutObjectCommand({
- Bucket: bucket.Name,
- Key: img.key,
- Body: img.blob,
- ContentType: img.contentType,
- ACL: 'public-read',
- Metadata: {
- filename: img.filename,
- createdAt: new Date().toISOString(),
- },
- }),
- );
- }
- console.log(` ✓ ${previewImages.length} preview images`);
-
- // 6. Models (without latestVersionId — set after versions exist)
- const models = [
- { id: ids.wolfSheep, visibility: 'public' as const, isEndorsed: true },
- { id: ids.fireSpread, visibility: 'public' as const, isEndorsed: true },
- { id: ids.antForaging, visibility: 'public' as const, isEndorsed: false },
- { id: ids.virusNetwork, visibility: 'private' as const, isEndorsed: false },
- {
- id: ids.wolfSheepFork,
- visibility: 'unlisted' as const,
- isEndorsed: false,
- parentModelId: ids.wolfSheep,
- },
- { id: ids.trafficBasic, visibility: 'public' as const, isEndorsed: false },
- {
- id: ids.traffic2Lanes,
- visibility: 'public' as const,
- isEndorsed: false,
- parentModelId: ids.trafficBasic,
- },
- ];
-
- for (const m of models) {
- await prisma.model.upsert({
- where: { id: m.id },
- update: {},
- create: m,
- });
- }
- console.log(` ✓ ${models.length} models`);
-
- // 7. Model versions
- const now = new Date();
- const oneWeekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
-
- const versions = [
- {
- modelId: ids.wolfSheep,
- versionNumber: 1,
- title: 'Wolf Sheep Predation',
- description:
- 'A classic predator-prey model exploring population dynamics between wolves, sheep, and grass.',
- netlogoFileKey: seedNlogoxFiles.wolfSheepNlogox1.key,
- netlogoVersion: '7.0.0',
- infoTab: seedNlogoxFiles.wolfSheepNlogox1.infoTab,
- createdAt: oneWeekAgo,
- finalizedAt: now,
- previewImageFileKey: seedNlogoxFiles.wolfSheepNlogox1.previewImage.key,
- },
- {
- modelId: ids.wolfSheep,
- versionNumber: 2,
- title: 'Wolf Sheep Predation',
- description: 'Updated with energy-based movement and grass regrowth mechanics.',
- netlogoFileKey: seedNlogoxFiles.wolfSheepNlogox2.key,
- netlogoVersion: '7.0.3',
- infoTab: seedNlogoxFiles.wolfSheepNlogox2.infoTab,
- previewImageFileKey: seedNlogoxFiles.wolfSheepNlogox2.previewImage.key,
- },
- {
- modelId: ids.wolfSheepFork,
- versionNumber: 1,
- title: 'Wolf Sheep - Seasonal Variant',
- description: 'A fork of Wolf Sheep Predation that adds seasonal grass growth patterns.',
- netlogoFileKey: seedNlogoxFiles.wolfSheepNlogoxFork.key,
- netlogoVersion: '7.0.0',
- infoTab: seedNlogoxFiles.wolfSheepNlogoxFork.infoTab,
- previewImageFileKey: seedNlogoxFiles.wolfSheepNlogoxFork.previewImage.key,
- },
- {
- modelId: ids.trafficBasic,
- versionNumber: 1,
- title: 'Traffic Basic',
- description: 'A simple traffic flow model demonstrating basic congestion dynamics.',
- netlogoFileKey: seedNlogoxFiles.trafficBasicNlogox.key,
- netlogoVersion: '6.4.0',
- infoTab: seedNlogoxFiles.trafficBasicNlogox.infoTab,
- previewImageFileKey: seedNlogoxFiles.trafficBasicNlogox.previewImage.key,
- },
- {
- modelId: ids.trafficBasic,
- versionNumber: 2,
- title: 'Traffic Grid',
- description:
- 'An extension of the basic traffic model that simulates a grid of intersections and traffic lights.',
- netlogoFileKey: seedNlogoxFiles.trafficGridNlogox.key,
- netlogoVersion: '7.0.0',
- infoTab: seedNlogoxFiles.trafficGridNlogox.infoTab,
- previewImageFileKey: seedNlogoxFiles.trafficGridNlogox.previewImage.key,
- },
- {
- modelId: ids.traffic2Lanes,
- versionNumber: 1,
- title: 'Traffic – 2 Lanes',
- description:
- 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse varius enim in eros. Lorem ipsum dolor sit amet, consect etur adipiscing elit. Suspendisse varius enim in eros.Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse varius enim in eros. Suspendisse vauspendisse va.',
-
- netlogoFileKey: seedNlogoxFiles.traffic2LanesNlogox.key,
- netlogoVersion: '7.0.0',
- infoTab: seedNlogoxFiles.traffic2LanesNlogox.infoTab,
- previewImageFileKey: seedNlogoxFiles.traffic2LanesNlogox.previewImage.key,
- },
- {
- modelId: ids.fireSpread,
- versionNumber: 1,
- title: 'Fire',
- description:
- "This project simulates the spread of a fire through a forest. It shows that the fire's chance of reaching the right edge of the forest depends critically on the density of trees. This is an example of a common feature of complex systems, the presence of a non-linear threshold or critical parameter.",
- netlogoFileKey: seedNlogoxFiles.fire.key,
- netlogoVersion: '6.4.0',
- infoTab: '## WHAT IS IT?\n\nThis model simulates fire spreading through a forest.',
- previewImageFileKey: seedNlogoxFiles.fire.previewImage.key,
- },
- {
- modelId: ids.antForaging,
- versionNumber: 1,
- title: 'Ants',
- description:
- 'In this project, a colony of ants forages for food. Though each ant follows a set of simple rules, the colony as a whole acts in a sophisticated way.',
- netlogoFileKey: seedNlogoxFiles.ants.key,
- netlogoVersion: '6.3.0',
- infoTab: '## WHAT IS IT?\n\nThis model demonstrates emergent path-finding behavior.',
- },
- {
- modelId: ids.virusNetwork,
- versionNumber: 1,
- title: 'Virus on a Network',
- description:
- 'Explores how a virus spreads through a network topology and the impact of vaccination strategies.',
- netlogoFileKey: keys.virusNetworkNlogox,
- netlogoVersion: '6.4.0',
- infoTab:
- '## WHAT IS IT?\n\nThis model shows virus spread dynamics on various network topologies.',
- },
- ];
-
- for (const v of versions) {
- await prisma.modelVersion.upsert({
- where: { modelId_versionNumber: { modelId: v.modelId, versionNumber: v.versionNumber } },
- update: {},
- create: v,
- });
- }
- console.log(` ✓ ${versions.length} model versions`);
-
- // 8. Set latest version pointers
- const modelUpdates = [
- { id: ids.wolfSheep, data: { latestVersionNumber: 2 } },
- { id: ids.fireSpread, data: { latestVersionNumber: 1 } },
- { id: ids.antForaging, data: { latestVersionNumber: 1 } },
- { id: ids.virusNetwork, data: { latestVersionNumber: 1 } },
- {
- id: ids.wolfSheepFork,
- data: { latestVersionNumber: 1, parentVersionNumber: 2 },
- },
- { id: ids.trafficBasic, data: { latestVersionNumber: 2 } },
- { id: ids.traffic2Lanes, data: { latestVersionNumber: 1, parentVersionNumber: 2 } },
- ];
-
- for (const m of modelUpdates) {
- await prisma.model.update({ where: { id: m.id }, data: m.data });
- }
- console.log(' ✓ latest version pointers set');
-
- // 9. Model version files (supplementary files attached to versions)
- const mvFiles = [
- { id: ids.mvFile1, modelId: ids.wolfSheep, versionNumber: 1, fileKey: keys.wolfSheepData },
- { id: ids.mvFile2, modelId: ids.wolfSheep, versionNumber: 2, fileKey: keys.wolfSheepData },
- ];
-
- for (const mvf of mvFiles) {
- await prisma.modelVersionFile.upsert({
- where: { id: mvf.id },
- update: {},
- create: mvf,
- });
- }
- console.log(` ✓ ${mvFiles.length} model version files`);
-
- // 10. Model version tags
- const spreadTags = (modelId: string, versionNumber: number, tagIds: string[]) =>
- tagIds.map((tagId) => ({ modelId, versionNumber, tagId }));
-
- const versionTags = [
- spreadTags(ids.wolfSheep, 1, [ids.tagEcology, ids.tagPredatorPrey, ids.tagBiology]),
- spreadTags(ids.wolfSheep, 2, [
- ids.tagEcology,
- ids.tagPredatorPrey,
- ids.tagBiology,
- ids.tagEmergence,
- ]),
- spreadTags(ids.fireSpread, 1, [ids.tagFire, ids.tagEmergence]),
- spreadTags(ids.antForaging, 1, [ids.tagBiology, ids.tagSwarmIntelligence, ids.tagEmergence]),
- spreadTags(ids.virusNetwork, 1, [ids.tagNetwork, ids.tagEpidemiology, ids.tagBiology]),
- spreadTags(ids.wolfSheepFork, 1, [ids.tagEcology, ids.tagPredatorPrey]),
- spreadTags(ids.trafficBasic, 1, [ids.tagEmergence]),
- spreadTags(ids.trafficBasic, 2, [ids.tagEmergence]),
- spreadTags(ids.traffic2Lanes, 1, [ids.tagEmergence]),
- ].flat();
-
- for (const vt of versionTags) {
- await prisma.modelVersionTag.upsert({
- where: {
- modelId_versionNumber_tagId: {
- modelId: vt.modelId,
- versionNumber: vt.versionNumber,
- tagId: vt.tagId,
- },
- },
- update: {},
- create: vt,
- });
- }
- console.log(` ✓ ${versionTags.length} version tags`);
-
- // 11. Model additional files (model-level, version-tagged)
- const additionalFiles = [
- {
- id: ids.additionalFile1,
- modelId: ids.wolfSheep,
- taggedVersionNumber: 1,
- fileKey: keys.wolfSheepReadme,
- },
- {
- id: ids.additionalFile2,
- modelId: ids.fireSpread,
- taggedVersionNumber: 1,
- fileKey: keys.fireSpreadCsv,
- },
- ];
-
- for (const af of additionalFiles) {
- await prisma.modelAdditionalFile.upsert({
- where: { id: af.id },
- update: {},
- create: af,
- });
- }
- console.log(` ✓ ${additionalFiles.length} additional files`);
-
- // 12. Model authors
- const authors = [
- { modelId: ids.wolfSheep, userId: ids.alice, role: 'owner' as const },
- { modelId: ids.wolfSheep, userId: ids.bob, role: 'contributor' as const },
- { modelId: ids.fireSpread, userId: ids.bob, role: 'owner' as const },
- { modelId: ids.fireSpread, userId: ids.carol, role: 'contributor' as const },
- { modelId: ids.antForaging, userId: ids.carol, role: 'owner' as const },
- { modelId: ids.virusNetwork, userId: ids.alice, role: 'owner' as const },
- { modelId: ids.virusNetwork, userId: ids.carol, role: 'contributor' as const },
- { modelId: ids.wolfSheepFork, userId: ids.dave, role: 'owner' as const },
- { modelId: ids.trafficBasic, userId: ids.alice, role: 'owner' as const },
- { modelId: ids.trafficBasic, userId: ids.bob, role: 'contributor' as const },
- { modelId: ids.traffic2Lanes, userId: ids.bob, role: 'owner' as const },
- ];
-
- for (const a of authors) {
- await prisma.modelAuthor.upsert({
- where: { modelId_userId: { modelId: a.modelId, userId: a.userId } },
- update: {},
- create: a,
- });
- }
- console.log(` ✓ ${authors.length} model authors`);
-
- // 13. Model permissions
- const permissions = [
- {
- id: ids.permBobReadWolfSheep,
- modelId: ids.wolfSheep,
- granteeUserId: ids.bob,
- permissionLevel: 'read' as const,
- },
- {
- id: ids.permCarolWriteFireSpread,
- modelId: ids.fireSpread,
- granteeUserId: ids.carol,
- permissionLevel: 'write' as const,
- },
- {
- id: ids.permPublicReadAntForaging,
- modelId: ids.antForaging,
- granteeUserId: null,
- permissionLevel: 'read' as const,
- },
- {
- id: getNonRandomUUID('60000000', 100),
- modelId: ids.trafficBasic,
- granteeUserId: null,
- permissionLevel: 'read' as const,
- },
- {
- id: getNonRandomUUID('60000000', 100),
- modelId: ids.traffic2Lanes,
- granteeUserId: null,
- permissionLevel: 'read' as const,
- },
- ];
-
- for (const p of permissions) {
- await prisma.modelPermission.upsert({
- where: { id: p.id },
- update: {},
- create: p,
- });
- }
- console.log(` ✓ ${permissions.length} model permissions`);
-
- // 14. Events (domain event log)
- const events = [
- {
- id: ids.event1,
- type: 'model.created',
- actorId: ids.alice,
- resourceType: 'model',
- resourceId: ids.wolfSheep,
- payload: { title: 'Wolf Sheep Predation', visibility: 'public' },
- },
- {
- id: ids.event2,
- type: 'model_version.created',
- actorId: ids.alice,
- resourceType: 'model_version',
- resourceId: `${ids.wolfSheep}:1`,
- payload: { modelId: ids.wolfSheep, versionNumber: 1 },
- },
- {
- id: ids.event3,
- type: 'model_version.created',
- actorId: ids.alice,
- resourceType: 'model_version',
- resourceId: `${ids.wolfSheep}:2`,
- payload: { modelId: ids.wolfSheep, versionNumber: 2 },
- processedAt: new Date(),
- },
- {
- id: ids.event4,
- type: 'model.created',
- actorId: ids.bob,
- resourceType: 'model',
- resourceId: ids.fireSpread,
- payload: { title: 'Fire Spread', visibility: 'public' },
- processedAt: new Date(),
- },
- {
- id: ids.event5,
- type: 'model.forked',
- actorId: ids.dave,
- resourceType: 'model',
- resourceId: ids.wolfSheepFork,
- payload: { parentModelId: ids.wolfSheep, parentVersionNumber: 2 },
- },
- ];
-
- for (const e of events) {
- await prisma.event.upsert({
- where: { id: e.id },
- update: {},
- create: e,
- });
- }
- console.log(` ✓ ${events.length} events`);
-
- console.log('\nSeed completed successfully.');
-}
-
-main()
- .then(async () => {
- await prisma.$disconnect();
- })
- .catch(async (e) => {
- console.error(e);
- await prisma.$disconnect();
- process.exit(1);
- });
+// Entry point referenced by `prisma db seed` (see package.json).
+// The seed is manifest-driven; see prisma/seed/ for the data and loaders.
+import './seed/index.js';
\ No newline at end of file
diff --git a/apps/modeling-commons-backend/prisma/seed/assets.ts b/apps/modeling-commons-backend/prisma/seed/assets.ts
new file mode 100644
index 00000000..1a84392d
--- /dev/null
+++ b/apps/modeling-commons-backend/prisma/seed/assets.ts
@@ -0,0 +1,194 @@
+import fs from 'node:fs';
+import path from 'node:path';
+import { PutObjectCommand } from '@aws-sdk/client-s3';
+import { s3, bucket } from './providers.js';
+
+const seedFilesPath = path.join(import.meta.dirname, '..', 'seed-files');
+
+const MIME_BY_EXT: Record = {
+ '.png': 'image/png',
+ '.jpg': 'image/jpeg',
+ '.jpeg': 'image/jpeg',
+ '.gif': 'image/gif',
+ '.bmp': 'image/bmp',
+ '.webp': 'image/webp',
+ '.nlogox': 'application/xml',
+ '.nlogo': 'text/plain',
+ '.csv': 'text/csv',
+ '.md': 'text/markdown',
+ '.txt': 'text/plain',
+};
+
+const PUBLIC_PREFIX = 'files/public';
+
+function mimeFor(filename: string): string {
+ return MIME_BY_EXT[path.extname(filename).toLowerCase()] ?? 'application/octet-stream';
+}
+
+/** Pull the human-readable Info tab out of an nlogox file, if present. */
+function extractInfoTab(xml: string): string | undefined {
+ const match = xml.match(/\s*\s*<\/info>/);
+ return match?.[1]?.trim() || undefined;
+}
+
+export interface NlogoxAsset {
+ /** S3 key for the (private) model file. */
+ key: string;
+ filename: string;
+ blob: Buffer;
+ contentType: string;
+ sizeBytes: bigint;
+ infoTab: string | undefined;
+ preview?: PreviewAsset;
+}
+
+export interface PreviewAsset {
+ /** Public S3 key for the preview image. */
+ key: string;
+ filename: string;
+ blob: Buffer;
+ contentType: string;
+}
+
+function loadPreview(previewFilename: string): PreviewAsset {
+ const blob = fs.readFileSync(path.join(seedFilesPath, previewFilename));
+ return {
+ key: `${PUBLIC_PREFIX}/preview-images/seed/${previewFilename}`,
+ filename: previewFilename,
+ blob,
+ contentType: mimeFor(previewFilename),
+ };
+}
+
+/** Load a real `.nlogox` file shipped in `seed-files/`, with its Info tab + optional preview. */
+export function loadNlogox(filename: string, previewFilename?: string): NlogoxAsset {
+ const filepath = path.join(seedFilesPath, filename);
+ const content = fs.readFileSync(filepath, 'utf-8');
+ return {
+ key: `uploads/models/${filename}`,
+ filename,
+ blob: Buffer.from(content, 'utf-8'),
+ contentType: 'application/xml',
+ sizeBytes: BigInt(fs.statSync(filepath).size),
+ infoTab: extractInfoTab(content),
+ preview: previewFilename ? loadPreview(previewFilename) : undefined,
+ };
+}
+
+/**
+ * Synthesize a placeholder `.nlogox` for a library model we don't ship a real
+ * file for. Cards still render (title, description, preview); the file just
+ * won't open in the NetLogo viewer.
+ */
+export function fakeNlogox(slug: string, title: string, previewFilename?: string): NlogoxAsset {
+ const xml = `
+
+
+
+`;
+ const filename = `${slug}.nlogox`;
+ const blob = Buffer.from(xml, 'utf-8');
+ return {
+ key: `uploads/models/${filename}`,
+ filename,
+ blob,
+ contentType: 'application/xml',
+ sizeBytes: BigInt(blob.byteLength),
+ infoTab: extractInfoTab(xml),
+ preview: previewFilename ? loadPreview(previewFilename) : undefined,
+ };
+}
+
+export interface SupplementaryAsset {
+ key: string;
+ filename: string;
+ blob: Buffer;
+ contentType: string;
+ sizeBytes: bigint;
+}
+
+export function textAsset(
+ filename: string,
+ content: string,
+ keyPrefix = 'uploads/models',
+): SupplementaryAsset {
+ const blob = Buffer.from(content, 'utf-8');
+ return {
+ key: `${keyPrefix.replace(/\/+$/, '')}/${filename}`,
+ filename,
+ blob,
+ contentType: mimeFor(filename),
+ sizeBytes: BigInt(blob.byteLength),
+ };
+}
+
+/**
+ * Uploads to object storage, de-duplicated by key. Public assets (preview
+ * images) live under `files/public/` and are uploaded with a public-read ACL
+ * so `fileService.getUrl` serves them directly.
+ */
+export class AssetUploader {
+ private uploaded = new Set();
+
+ async put(opts: {
+ key: string;
+ body: Buffer;
+ contentType: string;
+ filename: string;
+ public?: boolean;
+ }): Promise {
+ if (this.uploaded.has(opts.key)) return;
+ await s3.send(
+ new PutObjectCommand({
+ Bucket: bucket,
+ Key: opts.key,
+ Body: opts.body,
+ ContentType: opts.contentType,
+ ...(opts.public ? { ACL: 'public-read' } : {}),
+ Metadata: { filename: opts.filename, createdAt: new Date().toISOString() },
+ }),
+ );
+ this.uploaded.add(opts.key);
+ }
+
+ async putNlogox(asset: NlogoxAsset): Promise {
+ await this.put({
+ key: asset.key,
+ body: asset.blob,
+ contentType: asset.contentType,
+ filename: asset.filename,
+ });
+ if (asset.preview) {
+ await this.put({
+ key: asset.preview.key,
+ body: asset.preview.blob,
+ contentType: asset.preview.contentType,
+ filename: asset.preview.filename,
+ public: true,
+ });
+ }
+ }
+
+ async putSupplementary(asset: SupplementaryAsset): Promise {
+ await this.put({
+ key: asset.key,
+ body: asset.blob,
+ contentType: asset.contentType,
+ filename: asset.filename,
+ });
+ }
+
+ get count(): number {
+ return this.uploaded.size;
+ }
+}
diff --git a/apps/modeling-commons-backend/prisma/seed/builders.ts b/apps/modeling-commons-backend/prisma/seed/builders.ts
deleted file mode 100644
index 7c3b84c6..00000000
--- a/apps/modeling-commons-backend/prisma/seed/builders.ts
+++ /dev/null
@@ -1,344 +0,0 @@
-import { PutObjectCommand } from '@aws-sdk/client-s3';
-import { prisma, s3, bucket } from './providers.js';
-import { id } from './id.js';
-import type { NlogoxFile } from './files.js';
-
-export class UserBuilder {
- id = id('user0000');
- name = 'Unnamed User';
- email = `${this.id}@example.com`;
- emailVerified = true;
- systemRole: 'user' | 'moderator' | 'admin' = 'user';
- userKind: 'researcher' | 'teacher' | 'student' | 'other' = 'researcher';
- isProfilePublic = false;
-
- async upsert() {
- await prisma.user.upsert({
- where: { id: this.id },
- update: {},
- create: {
- id: this.id,
- name: this.name,
- email: this.email,
- emailVerified: this.emailVerified,
- systemRole: this.systemRole,
- userKind: this.userKind,
- isProfilePublic: this.isProfilePublic,
- },
- });
-
- // Auto-create credential account
- await prisma.account.upsert({
- where: { id: id('account0') },
- update: {},
- create: {
- id: id('account0'),
- userId: this.id,
- accountId: this.id,
- providerId: this.id,
- },
- });
-
- return this;
- }
-}
-
-export class SessionBuilder {
- id = id('session0');
- user!: UserBuilder;
- token = `dev-session-token-${this.id}`;
- expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
- ipAddress = '127.0.0.1';
- userAgent = 'Mozilla/5.0 (seed)';
-
- async upsert() {
- await prisma.session.upsert({
- where: { id: this.id },
- update: {},
- create: {
- id: this.id,
- userId: this.user.id,
- token: this.token,
- expiresAt: this.expiresAt,
- ipAddress: this.ipAddress,
- userAgent: this.userAgent,
- },
- });
- return this;
- }
-}
-
-export class TagBuilder {
- id = id('tag00000');
- name: string;
-
- constructor(name: string) {
- this.name = name;
- }
-
- async upsert() {
- await prisma.tag.upsert({
- where: { id: this.id },
- update: {},
- create: { id: this.id, name: this.name },
- });
- return this;
- }
-}
-
-export class FileUploader {
- key: string;
- body: Buffer;
- contentType: string;
- filename: string;
-
- constructor(key: string, body: Buffer, contentType: string, filename: string) {
- this.key = key;
- this.body = body;
- this.contentType = contentType;
- this.filename = filename;
- }
-
- async upload() {
- await s3.send(
- new PutObjectCommand({
- Bucket: bucket,
- Key: this.key,
- Body: this.body,
- ContentType: this.contentType,
- Metadata: {
- filename: this.filename,
- createdAt: new Date().toISOString(),
- },
- }),
- );
- return this;
- }
-
- /** Upload all nlogox files from a record of NlogoxFile values. */
- static async uploadNlogoxFiles(files: Record) {
- for (const f of Object.values(files)) {
- await new FileUploader(f.key, f.blob, f.contentType, f.filename).upload();
- }
- }
-}
-
-export class ModelVersionBuilder {
- versionNumber!: number;
- title = '';
- description = '';
- netlogoFileKey = '';
- netlogoVersion = '7.0.0';
- infoTab?: string;
- createdAt?: Date;
- finalizedAt?: Date;
- previewImage?: Buffer;
- tags: TagBuilder[] = [];
- supplementaryFileKeys: string[] = [];
-
- // Set by ModelBuilder when adding the version
- _modelId!: string;
-
- async upsert() {
- const data: Record = {
- modelId: this._modelId,
- versionNumber: this.versionNumber,
- title: this.title,
- description: this.description,
- netlogoFileKey: this.netlogoFileKey,
- netlogoVersion: this.netlogoVersion,
- infoTab: this.infoTab,
- };
- if (this.createdAt) data.createdAt = this.createdAt;
- if (this.finalizedAt) data.finalizedAt = this.finalizedAt;
- if (this.previewImage) data.previewImage = this.previewImage;
-
- await prisma.modelVersion.upsert({
- where: {
- modelId_versionNumber: {
- modelId: this._modelId,
- versionNumber: this.versionNumber,
- },
- },
- update: {},
- create: data,
- });
-
- // Tags
- for (const tag of this.tags) {
- await prisma.modelVersionTag.upsert({
- where: {
- modelId_versionNumber_tagId: {
- modelId: this._modelId,
- versionNumber: this.versionNumber,
- tagId: tag.id,
- },
- },
- update: {},
- create: {
- modelId: this._modelId,
- versionNumber: this.versionNumber,
- tagId: tag.id,
- },
- });
- }
-
- // Supplementary files
- for (const fileKey of this.supplementaryFileKeys) {
- await prisma.modelVersionFile.upsert({
- where: { id: id('mvfile000') },
- update: {},
- create: {
- id: id('mvfile000'),
- modelId: this._modelId,
- versionNumber: this.versionNumber,
- fileKey,
- },
- });
- }
-
- return this;
- }
-
- /** Convenience: populate from a NlogoxFile */
- fromNlogox(nlogox: NlogoxFile) {
- this.netlogoFileKey = nlogox.key;
- this.infoTab = nlogox.infoTab;
- this.previewImage = nlogox.previewImage.blob;
- return this;
- }
-}
-
-export class ModelBuilder {
- id = id('model000');
- visibility: 'public' | 'private' | 'unlisted' = 'public';
- isEndorsed = false;
-
- parent?: ModelBuilder;
- parentVersionNumber?: number;
- latestVersionNumber = 1;
-
- private _versions: ModelVersionBuilder[] = [];
- private _authors: { user: UserBuilder; role: 'owner' | 'contributor' }[] = [];
- private _permissions: { granteeUserId: string | null; permissionLevel: 'read' | 'write' }[] = [];
- private _additionalFiles: { taggedVersionNumber: number; fileKey: string }[] = [];
-
- addVersion(v: ModelVersionBuilder) {
- v._modelId = this.id;
- this._versions.push(v);
- this.latestVersionNumber = Math.max(this.latestVersionNumber, v.versionNumber);
- return this;
- }
-
- addAuthor(user: UserBuilder, role: 'owner' | 'contributor' = 'contributor') {
- this._authors.push({ user, role });
- return this;
- }
-
- addPermission(granteeUserId: string | null, level: 'read' | 'write' = 'read') {
- this._permissions.push({ granteeUserId, permissionLevel: level });
- return this;
- }
-
- addAdditionalFile(taggedVersionNumber: number, fileKey: string) {
- this._additionalFiles.push({ taggedVersionNumber, fileKey });
- return this;
- }
-
- async upsert() {
- // Model record
- await prisma.model.upsert({
- where: { id: this.id },
- update: {},
- create: {
- id: this.id,
- visibility: this.visibility,
- isEndorsed: this.isEndorsed,
- ...(this.parent && { parentModelId: this.parent.id }),
- },
- });
-
- // Versions
- for (const v of this._versions) {
- await v.upsert();
- }
-
- // Set latest version pointer + parent version
- await prisma.model.update({
- where: { id: this.id },
- data: {
- latestVersionNumber: this.latestVersionNumber,
- ...(this.parentVersionNumber != null && {
- parentVersionNumber: this.parentVersionNumber,
- }),
- },
- });
-
- // Authors
- for (const a of this._authors) {
- await prisma.modelAuthor.upsert({
- where: { modelId_userId: { modelId: this.id, userId: a.user.id } },
- update: {},
- create: { modelId: this.id, userId: a.user.id, role: a.role },
- });
- }
-
- // Permissions
- for (const p of this._permissions) {
- const permId = id('perm0000');
- await prisma.modelPermission.upsert({
- where: { id: permId },
- update: {},
- create: {
- id: permId,
- modelId: this.id,
- granteeUserId: p.granteeUserId,
- permissionLevel: p.permissionLevel,
- },
- });
- }
-
- // Additional files
- for (const af of this._additionalFiles) {
- await prisma.modelAdditionalFile.upsert({
- where: { id: id('addfile00') },
- update: {},
- create: {
- id: id('addfile00'),
- modelId: this.id,
- taggedVersionNumber: af.taggedVersionNumber,
- fileKey: af.fileKey,
- },
- });
- }
-
- return this;
- }
-}
-
-export class EventBuilder {
- id = id('event000');
- type = '';
- actor!: UserBuilder;
- resourceType = '';
- resourceId = '';
- payload: Record = {};
- processedAt?: Date;
-
- async upsert() {
- await prisma.event.upsert({
- where: { id: this.id },
- update: {},
- create: {
- id: this.id,
- type: this.type,
- actorId: this.actor.id,
- resourceType: this.resourceType,
- resourceId: this.resourceId,
- payload: this.payload,
- ...(this.processedAt && { processedAt: this.processedAt }),
- },
- });
- return this;
- }
-}
diff --git a/apps/modeling-commons-backend/prisma/seed/files.ts b/apps/modeling-commons-backend/prisma/seed/files.ts
deleted file mode 100644
index b2d50779..00000000
--- a/apps/modeling-commons-backend/prisma/seed/files.ts
+++ /dev/null
@@ -1,68 +0,0 @@
-import fs from 'node:fs';
-import path from 'node:path';
-
-const seedFilesPath = path.join(import.meta.dirname, 'seed-files');
-
-const MIME_BY_EXT: Record = {
- '.png': 'image/png',
- '.jpg': 'image/jpeg',
- '.jpeg': 'image/jpeg',
- '.gif': 'image/gif',
- '.bmp': 'image/bmp',
- '.webp': 'image/webp',
-};
-
-function readInfoTab(_xmlContent: string): string {
- return `
- # Info Tab Content
- This is a placeholder for the info tab content extracted from the nlogox file.
- In a real implementation, you would parse the XML and extract the relevant section.
- `;
-}
-
-export interface NlogoxFile {
- key: string;
- filename: string;
- blob: Buffer;
- contentType: 'application/xml';
- sizeBytes: bigint;
- infoTab: string;
- previewImage: { blob: Buffer; contentType: string };
-}
-
-export function readNlogox(filename: string, previewImg: string): NlogoxFile {
- const filepath = path.join(seedFilesPath, filename);
- const stats = fs.statSync(filepath);
- const content = fs.readFileSync(filepath, 'utf-8');
-
- const previewPath = path.join(seedFilesPath, previewImg);
- const ext = path.extname(previewImg).toLowerCase();
-
- return {
- key: `uploads/models/${filename}`,
- filename,
- blob: Buffer.from(content, 'utf-8'),
- contentType: 'application/xml',
- sizeBytes: BigInt(stats.size),
- infoTab: readInfoTab(content),
- previewImage: {
- blob: fs.readFileSync(previewPath),
- contentType: MIME_BY_EXT[ext] || 'application/octet-stream',
- },
- };
-}
-
-export function fakeNlogox(title: string): Buffer {
- return Buffer.from(
- `\n${title}`,
- 'utf-8',
- );
-}
-
-export function fakeCsv(): Buffer {
- return Buffer.from('tick,wolves,sheep\n0,50,100\n1,48,105\n2,45,112\n', 'utf-8');
-}
-
-export function fakeReadme(): Buffer {
- return Buffer.from('# Wolf Sheep Predation\n\nA classic predator-prey model.', 'utf-8');
-}
diff --git a/apps/modeling-commons-backend/prisma/seed/id.ts b/apps/modeling-commons-backend/prisma/seed/id.ts
index cf964256..2e3cf609 100644
--- a/apps/modeling-commons-backend/prisma/seed/id.ts
+++ b/apps/modeling-commons-backend/prisma/seed/id.ts
@@ -1,16 +1,45 @@
-const counters: Record = {};
+import { createHash } from 'node:crypto';
+
+const SEED_NAMESPACE = 'modeling-commons:seed';
/**
- * Deterministic UUID-shaped string, auto-incrementing per prefix.
- * Prefix is padded/truncated to 8 chars.
+ * Deterministic, UUID-shaped id derived from a stable natural key.
+ *
+ * The same parts always produce the same id, so every record can be upserted
+ * by id and the whole seed is idempotent across runs - no fragile call-order
+ * counters. Output is a valid v5-style UUID (version + variant bits set).
*
- * id('user') => 'user0000-0000-4000-a000-000000000000'
- * id('user') => 'user0000-0000-4000-a000-000000000001'
+ * seedId('user', 'alice') => 'a1b2...'
+ * seedId('model', 'wolf-sheep') => 'c3d4...'
+ */
+export function seedId(...parts: Array): string {
+ const name = `${SEED_NAMESPACE}:${parts.join(':')}`;
+ const bytes = createHash('sha1').update(name).digest().subarray(0, 16);
+
+ bytes[6] = (bytes[6]! & 0x0f) | 0x50; // version 5
+ bytes[8] = (bytes[8]! & 0x3f) | 0x80; // RFC 4122 variant
+
+ const hex = Buffer.from(bytes).toString('hex');
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
+}
+
+/**
+ * Tiny deterministic PRNG (mulberry32) seeded from a string. Used to scatter
+ * timestamps / pick pools when generating engagement data so the seed produces
+ * the same realistic-looking spread on every run.
*/
-export function id(prefix: string): string {
- const padded = prefix.slice(0, 8).padEnd(8, '0');
- counters[padded] ??= 0;
- const suffix = counters[padded].toString().padStart(12, '0');
- counters[padded]++;
- return `${padded}-0000-4000-a000-${suffix}`;
+export function seededRandom(seed: string): () => number {
+ let h = 1779033703 ^ seed.length;
+ for (let i = 0; i < seed.length; i++) {
+ h = Math.imul(h ^ seed.charCodeAt(i), 3432918353);
+ h = (h << 13) | (h >>> 19);
+ }
+ let a = h >>> 0;
+ return () => {
+ a |= 0;
+ a = (a + 0x6d2b79f5) | 0;
+ let t = Math.imul(a ^ (a >>> 15), 1 | a);
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
+ };
}
diff --git a/apps/modeling-commons-backend/prisma/seed/index.ts b/apps/modeling-commons-backend/prisma/seed/index.ts
index 81af1f91..27434c41 100644
--- a/apps/modeling-commons-backend/prisma/seed/index.ts
+++ b/apps/modeling-commons-backend/prisma/seed/index.ts
@@ -1,329 +1,38 @@
import { prisma } from './providers.js';
+import { AssetUploader } from './assets.js';
+import { USERS, TAGS, MODELS, DRAFTS } from './manifest/index.js';
import {
- UserBuilder,
- SessionBuilder,
- TagBuilder,
- FileUploader,
- ModelBuilder,
- ModelVersionBuilder,
- EventBuilder,
-} from './builders.js';
-import { readNlogox, fakeNlogox, fakeCsv, fakeReadme } from './files.js';
+ loadUsers,
+ loadTags,
+ loadModels,
+ loadEngagement,
+ loadDrafts,
+ type IdMap,
+} from './loaders.js';
async function main() {
- console.log('Seeding database...');
+ console.log('Seeding database from manifest...');
+ const uploader = new AssetUploader();
- const alice = new UserBuilder();
- alice.name = 'Alice Bob';
- alice.email = 'alice@example.com';
- alice.userKind = 'researcher';
- alice.isProfilePublic = true;
- await alice.upsert();
+ const users = await loadUsers(USERS);
+ console.log(` ✓ ${users.size} users (+ credential accounts, dev sessions)`);
- const bob = new UserBuilder();
- bob.name = 'Bob Rand';
- bob.email = 'bob@example.com';
- bob.userKind = 'teacher';
- bob.isProfilePublic = true;
- await bob.upsert();
+ const tags = await loadTags(TAGS);
+ console.log(` ✓ ${tags.size} tags`);
- const carol = new UserBuilder();
- carol.name = 'Carol Tisue';
- carol.email = 'carol@example.com';
- carol.systemRole = 'moderator';
- carol.userKind = 'researcher';
- await carol.upsert();
+ const models = await loadModels(MODELS, users, tags, uploader);
+ console.log(` ✓ ${models.length} models with versions, authors, permissions, files`);
+ console.log(` ✓ ${uploader.count} objects uploaded to storage`);
- const dave = new UserBuilder();
- dave.name = 'Dave Student';
- dave.email = 'dave@example.com';
- dave.emailVerified = false;
- dave.userKind = 'student';
- await dave.upsert();
-
- const admin = new UserBuilder();
- admin.name = 'Admin';
- admin.email = 'admin@example.com';
- admin.systemRole = 'admin';
- admin.userKind = 'other';
- await admin.upsert();
-
- console.log(' ✓ 5 users + accounts');
-
- const sessionAlice = new SessionBuilder();
- sessionAlice.user = alice;
- sessionAlice.token = 'dev-session-token-alice-000001';
- await sessionAlice.upsert();
-
- const sessionBob = new SessionBuilder();
- sessionBob.user = bob;
- sessionBob.token = 'dev-session-token-bob-000002';
- await sessionBob.upsert();
-
- console.log(' ✓ 2 sessions');
-
- const ecology = new TagBuilder('ecology');
- const predatorPrey = new TagBuilder('predator-prey');
- const fire = new TagBuilder('fire');
- const emergence = new TagBuilder('emergence');
- const biology = new TagBuilder('biology');
- const network = new TagBuilder('network');
- const epidemiology = new TagBuilder('epidemiology');
- const swarmIntelligence = new TagBuilder('swarm-intelligence');
-
- await Promise.all(
- [ecology, predatorPrey, fire, emergence, biology, network, epidemiology, swarmIntelligence].map(
- (t) => t.upsert(),
- ),
+ const engagement = await loadEngagement(models, users);
+ console.log(
+ ` ✓ ${engagement.likes} likes, ${engagement.interactions} interactions, ${engagement.events} audit events`,
);
- console.log(' ✓ 8 tags');
-
- const nlogoxFiles = {
- wolfSheepV1: readNlogox('wolf-sheep-predation.nlogox', 'wolf-sheep-preview.png'),
- wolfSheepV2: readNlogox('wolf-sheep-predation-v2.nlogox', 'wolf-sheep-preview.png'),
- wolfSheepFork: readNlogox('wolf-sheep-predation-fork.nlogox', 'wolf-sheep-preview.png'),
- trafficGrid: readNlogox('traffic-grid.nlogox', 'traffic-grid-preview.png'),
- trafficBasic: readNlogox('traffic-basic.nlogox', 'traffic-grid-preview.png'),
- traffic2Lanes: readNlogox('traffic-2-lanes.nlogox', 'traffic-2-lanes-preview.png'),
- fire: readNlogox('fire.nlogox', 'fire-preview.png'),
- ants: readNlogox('ants.nlogox', 'ants-preview.png'),
- };
-
- const extraKeys = {
- virusNetwork: 'uploads/models/virus-network.nlogox',
- wolfSheepReadme: 'uploads/models/wolf-sheep-readme.md',
- wolfSheepData: 'uploads/models/wolf-sheep-data.csv',
- fireSpreadCsv: 'uploads/models/fire-spread-data.csv',
- };
-
- await FileUploader.uploadNlogoxFiles(nlogoxFiles);
- await new FileUploader(
- extraKeys.virusNetwork,
- fakeNlogox('Virus on a Network'),
- 'application/xml',
- 'virus-network.nlogox',
- ).upload();
- await new FileUploader(
- extraKeys.wolfSheepReadme,
- fakeReadme(),
- 'text/markdown',
- 'README.md',
- ).upload();
- await new FileUploader(
- extraKeys.wolfSheepData,
- fakeCsv(),
- 'text/csv',
- 'initial-data.csv',
- ).upload();
- await new FileUploader(
- extraKeys.fireSpreadCsv,
- fakeCsv(),
- 'text/csv',
- 'burn-results.csv',
- ).upload();
- console.log(' ✓ files uploaded');
-
- const now = new Date();
- const oneWeekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
-
- // Wolf Sheep Predation
- const wolfSheepV1 = new ModelVersionBuilder();
- wolfSheepV1.versionNumber = 1;
- wolfSheepV1.title = 'Wolf Sheep Predation';
- wolfSheepV1.description =
- 'A classic predator-prey model exploring population dynamics between wolves, sheep, and grass.';
- wolfSheepV1.fromNlogox(nlogoxFiles.wolfSheepV1);
- wolfSheepV1.netlogoVersion = '7.0.0';
- wolfSheepV1.createdAt = oneWeekAgo;
- wolfSheepV1.finalizedAt = now;
- wolfSheepV1.tags = [ecology, predatorPrey, biology];
- wolfSheepV1.supplementaryFileKeys = [extraKeys.wolfSheepData];
-
- const wolfSheepV2 = new ModelVersionBuilder();
- wolfSheepV2.versionNumber = 2;
- wolfSheepV2.title = 'Wolf Sheep Predation';
- wolfSheepV2.description = 'Updated with energy-based movement and grass regrowth mechanics.';
- wolfSheepV2.fromNlogox(nlogoxFiles.wolfSheepV2);
- wolfSheepV2.netlogoVersion = '7.0.3';
- wolfSheepV2.tags = [ecology, predatorPrey, biology, emergence];
- wolfSheepV2.supplementaryFileKeys = [extraKeys.wolfSheepData];
-
- const wolfSheep = new ModelBuilder();
- wolfSheep.isEndorsed = true;
- wolfSheep.addVersion(wolfSheepV1);
- wolfSheep.addVersion(wolfSheepV2);
- wolfSheep.addAuthor(alice, 'owner');
- wolfSheep.addAuthor(bob, 'contributor');
- wolfSheep.addPermission(bob.id, 'read');
- wolfSheep.addAdditionalFile(1, extraKeys.wolfSheepReadme);
- await wolfSheep.upsert();
-
- // Fire Spread
- const fireV1 = new ModelVersionBuilder();
- fireV1.versionNumber = 1;
- fireV1.title = 'Fire';
- fireV1.description =
- "This project simulates the spread of a fire through a forest. It shows that the fire's chance of reaching the right edge of the forest depends critically on the density of trees. This is an example of a common feature of complex systems, the presence of a non-linear threshold or critical parameter.";
- fireV1.fromNlogox(nlogoxFiles.fire);
- fireV1.netlogoVersion = '6.4.0';
- fireV1.infoTab = '## WHAT IS IT?\n\nThis model simulates fire spreading through a forest.';
- fireV1.tags = [fire, emergence];
-
- const fireSpread = new ModelBuilder();
- fireSpread.isEndorsed = true;
- fireSpread.addVersion(fireV1);
- fireSpread.addAuthor(bob, 'owner');
- fireSpread.addAuthor(carol, 'contributor');
- fireSpread.addPermission(carol.id, 'write');
- fireSpread.addAdditionalFile(1, extraKeys.fireSpreadCsv);
- await fireSpread.upsert();
-
- // Ant Foraging
- const antsV1 = new ModelVersionBuilder();
- antsV1.versionNumber = 1;
- antsV1.title = 'Ants';
- antsV1.description =
- 'In this project, a colony of ants forages for food. Though each ant follows a set of simple rules, the colony as a whole acts in a sophisticated way.';
- antsV1.fromNlogox(nlogoxFiles.ants);
- antsV1.netlogoVersion = '6.3.0';
- antsV1.infoTab = '## WHAT IS IT?\n\nThis model demonstrates emergent path-finding behavior.';
- antsV1.tags = [biology, swarmIntelligence, emergence];
-
- const antForaging = new ModelBuilder();
- antForaging.addVersion(antsV1);
- antForaging.addAuthor(carol, 'owner');
- antForaging.addPermission(null, 'read');
- await antForaging.upsert();
-
- // Virus on a Network
- const virusV1 = new ModelVersionBuilder();
- virusV1.versionNumber = 1;
- virusV1.title = 'Virus on a Network';
- virusV1.description =
- 'Explores how a virus spreads through a network topology and the impact of vaccination strategies.';
- virusV1.netlogoFileKey = extraKeys.virusNetwork;
- virusV1.netlogoVersion = '6.4.0';
- virusV1.infoTab =
- '## WHAT IS IT?\n\nThis model shows virus spread dynamics on various network topologies.';
- virusV1.tags = [network, epidemiology, biology];
-
- const virusNetwork = new ModelBuilder();
- virusNetwork.visibility = 'private';
- virusNetwork.addVersion(virusV1);
- virusNetwork.addAuthor(alice, 'owner');
- virusNetwork.addAuthor(carol, 'contributor');
- await virusNetwork.upsert();
-
- // Wolf Sheep Fork
- const wolfSheepForkV1 = new ModelVersionBuilder();
- wolfSheepForkV1.versionNumber = 1;
- wolfSheepForkV1.title = 'Wolf Sheep - Seasonal Variant';
- wolfSheepForkV1.description =
- 'A fork of Wolf Sheep Predation that adds seasonal grass growth patterns.';
- wolfSheepForkV1.fromNlogox(nlogoxFiles.wolfSheepFork);
- wolfSheepForkV1.netlogoVersion = '7.0.0';
- wolfSheepForkV1.tags = [ecology, predatorPrey];
-
- const wolfSheepFork = new ModelBuilder();
- wolfSheepFork.visibility = 'unlisted';
- wolfSheepFork.parent = wolfSheep;
- wolfSheepFork.parentVersionNumber = 2;
- wolfSheepFork.addVersion(wolfSheepForkV1);
- wolfSheepFork.addAuthor(dave, 'owner');
- await wolfSheepFork.upsert();
-
- // Traffic Basic
- const trafficBasicV1 = new ModelVersionBuilder();
- trafficBasicV1.versionNumber = 1;
- trafficBasicV1.title = 'Traffic Basic';
- trafficBasicV1.description =
- 'A simple traffic flow model demonstrating basic congestion dynamics.';
- trafficBasicV1.fromNlogox(nlogoxFiles.trafficBasic);
- trafficBasicV1.netlogoVersion = '6.4.0';
- trafficBasicV1.tags = [emergence];
-
- const trafficBasicV2 = new ModelVersionBuilder();
- trafficBasicV2.versionNumber = 2;
- trafficBasicV2.title = 'Traffic Grid';
- trafficBasicV2.description =
- 'An extension of the basic traffic model that simulates a grid of intersections and traffic lights.';
- trafficBasicV2.fromNlogox(nlogoxFiles.trafficGrid);
- trafficBasicV2.netlogoVersion = '7.0.0';
- trafficBasicV2.tags = [emergence];
-
- const trafficBasic = new ModelBuilder();
- trafficBasic.addVersion(trafficBasicV1);
- trafficBasic.addVersion(trafficBasicV2);
- trafficBasic.addAuthor(alice, 'owner');
- trafficBasic.addAuthor(bob, 'contributor');
- trafficBasic.addPermission(null, 'read');
- await trafficBasic.upsert();
-
- // Traffic 2 Lanes
- const traffic2LanesV1 = new ModelVersionBuilder();
- traffic2LanesV1.versionNumber = 1;
- traffic2LanesV1.title = 'Traffic – 2 Lanes';
- traffic2LanesV1.description =
- 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse varius enim in eros. Lorem ipsum dolor sit amet, consect etur adipiscing elit. Suspendisse varius enim in eros.Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse varius enim in eros. Suspendisse vauspendisse va.';
- traffic2LanesV1.fromNlogox(nlogoxFiles.traffic2Lanes);
- traffic2LanesV1.netlogoVersion = '7.0.0';
- traffic2LanesV1.tags = [emergence];
-
- const traffic2Lanes = new ModelBuilder();
- traffic2Lanes.parent = trafficBasic;
- traffic2Lanes.parentVersionNumber = 2;
- traffic2Lanes.addVersion(traffic2LanesV1);
- traffic2Lanes.addAuthor(bob, 'owner');
- traffic2Lanes.addPermission(null, 'read');
- await traffic2Lanes.upsert();
-
- console.log(' ✓ 7 models with versions, tags, authors, permissions');
-
- // ── Events ──────────────────────────────────────────────────────────────
-
- const e1 = new EventBuilder();
- e1.type = 'model.created';
- e1.actor = alice;
- e1.resourceType = 'model';
- e1.resourceId = wolfSheep.id;
- e1.payload = { title: 'Wolf Sheep Predation', visibility: 'public' };
- await e1.upsert();
-
- const e2 = new EventBuilder();
- e2.type = 'model_version.created';
- e2.actor = alice;
- e2.resourceType = 'model_version';
- e2.resourceId = `${wolfSheep.id}:1`;
- e2.payload = { modelId: wolfSheep.id, versionNumber: 1 };
- await e2.upsert();
-
- const e3 = new EventBuilder();
- e3.type = 'model_version.created';
- e3.actor = alice;
- e3.resourceType = 'model_version';
- e3.resourceId = `${wolfSheep.id}:2`;
- e3.payload = { modelId: wolfSheep.id, versionNumber: 2 };
- e3.processedAt = now;
- await e3.upsert();
-
- const e4 = new EventBuilder();
- e4.type = 'model.created';
- e4.actor = bob;
- e4.resourceType = 'model';
- e4.resourceId = fireSpread.id;
- e4.payload = { title: 'Fire Spread', visibility: 'public' };
- e4.processedAt = now;
- await e4.upsert();
- const e5 = new EventBuilder();
- e5.type = 'model.forked';
- e5.actor = dave;
- e5.resourceType = 'model';
- e5.resourceId = wolfSheepFork.id;
- e5.payload = { parentModelId: wolfSheep.id, parentVersionNumber: 2 };
- await e5.upsert();
+ const modelIds: IdMap = new Map(models.map((m) => [m.key, m.id]));
+ const draftCount = await loadDrafts(DRAFTS, users, modelIds, uploader);
+ console.log(` ✓ ${draftCount} model drafts`);
- console.log(' ✓ 5 events');
console.log('\nSeed completed successfully.');
}
@@ -333,4 +42,4 @@ main()
console.error(e);
await prisma.$disconnect();
process.exit(1);
- });
+ });
\ No newline at end of file
diff --git a/apps/modeling-commons-backend/prisma/seed/loaders.ts b/apps/modeling-commons-backend/prisma/seed/loaders.ts
new file mode 100644
index 00000000..badf67b4
--- /dev/null
+++ b/apps/modeling-commons-backend/prisma/seed/loaders.ts
@@ -0,0 +1,480 @@
+import { createHash } from 'node:crypto';
+import { prisma } from './providers.js';
+import { seedId, seededRandom } from './id.js';
+import { AssetUploader, loadNlogox, fakeNlogox, textAsset, type NlogoxAsset } from './assets.js';
+import {
+ isRealFile,
+ type DraftSeed,
+ type ModelFileSeed,
+ type ModelSeed,
+ type TagSeed,
+ type UserSeed,
+ type VersionSeed,
+} from './manifest/index.js';
+
+const DAY = 86_400_000;
+const NOW = Date.now();
+const daysAgo = (n: number) => new Date(NOW - n * DAY);
+
+export type IdMap = Map;
+
+const GEO_POOL = [
+ { country: 'US', region: 'IL', city: 'Chicago' },
+ { country: 'GB', region: 'ENG', city: 'London' },
+ { country: 'JP', region: '13', city: 'Tokyo' },
+ { country: 'IN', region: 'KA', city: 'Bangalore' },
+ { country: 'DE', region: 'BE', city: 'Berlin' },
+ { country: 'BR', region: 'SP', city: 'São Paulo' },
+ { country: 'NG', region: 'LA', city: 'Lagos' },
+ { country: 'AU', region: 'NSW', city: 'Sydney' },
+];
+
+const UA_POOL = [
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 Safari/605.1.15',
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/124.0 Safari/537.36',
+ 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/123.0 Safari/537.36',
+ 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 Mobile Safari/604.1',
+];
+
+const REFERER_POOL = ['https://www.google.com/', 'https://modelingcommons.org/browse', null, null];
+
+function mustGet(map: IdMap, key: string, kind: string): string {
+ const id = map.get(key);
+ if (!id) throw new Error(`Seed manifest references unknown ${kind}: "${key}"`);
+ return id;
+}
+
+const pick = (rng: () => number, pool: readonly T[]): T =>
+ pool[Math.floor(rng() * pool.length)]!;
+
+// ── Users ───────────────────────────────────────────────────────────────────
+
+export async function loadUsers(users: UserSeed[]): Promise {
+ const map: IdMap = new Map();
+
+ for (const u of users) {
+ const id = seedId('user', u.key);
+ map.set(u.key, id);
+ const createdAt = daysAgo(u.createdDaysAgo ?? 365);
+ const systemRole = u.systemRole ?? 'user';
+
+ // Reconcile mutable profile fields on every run so manifest edits propagate
+ // to an already-seeded database; createdAt is fixed on first insert only.
+ const profile = {
+ legacyId: u.legacyId ?? null,
+ name: u.name,
+ email: u.email,
+ emailVerified: u.emailVerified ?? true,
+ systemRole,
+ role: systemRole === 'user' ? null : systemRole,
+ userKind: u.userKind ?? 'researcher',
+ isProfilePublic: u.isProfilePublic ?? false,
+ bio: u.bio ?? null,
+ country: u.country ?? null,
+ affiliation: u.affiliation ?? null,
+ socialLinks: (u.socialLinks ?? undefined) as never,
+ dob: u.dob ? new Date(u.dob) : null,
+ onboardedAt: (u.onboarded ?? true) ? createdAt : null,
+ };
+
+ await prisma.user.upsert({
+ where: { id },
+ update: profile,
+ create: { id, createdAt, ...profile },
+ });
+
+ const accountId = seedId('account', u.key);
+ await prisma.account.upsert({
+ where: { id: accountId },
+ update: {},
+ create: { id: accountId, userId: id, accountId: id, providerId: 'credential' },
+ });
+
+ if (u.devSession) {
+ const sessionId = seedId('session', u.key);
+ await prisma.session.upsert({
+ where: { id: sessionId },
+ update: {},
+ create: {
+ id: sessionId,
+ userId: id,
+ token: `dev-session-${u.key}`,
+ expiresAt: new Date(NOW + 30 * DAY),
+ ipAddress: '127.0.0.1',
+ userAgent: 'Mozilla/5.0 (seed)',
+ },
+ });
+ }
+ }
+
+ return map;
+}
+
+export async function loadTags(tags: TagSeed[]): Promise {
+ const map: IdMap = new Map();
+
+ for (const t of tags) {
+ const id = seedId('tag', t.name);
+ map.set(t.name, id);
+ await prisma.tag.upsert({
+ where: { name: t.name },
+ update: { displayName: t.displayName },
+ create: { id, name: t.name, displayName: t.displayName, legacyId: t.legacyId ?? null },
+ });
+ }
+
+ return map;
+}
+
+export interface LoadedModel {
+ key: string;
+ id: string;
+ latestVersionNumber: number;
+ createdAt: Date;
+ deletedAt: Date | null;
+ owner: string;
+ popularity: ModelSeed['popularity'];
+ isFork: boolean;
+ parentModelId?: string;
+ parentVersionNumber?: number;
+ versionTitles: string[];
+ visibility: NonNullable;
+}
+
+function resolveAsset(model: ModelSeed, version: VersionSeed, versionNumber: number): NlogoxAsset {
+ const file: ModelFileSeed = version.file;
+ if (isRealFile(file)) return loadNlogox(file.file, file.preview);
+ return fakeNlogox(`${model.key}-v${versionNumber}`, version.title, file.preview);
+}
+
+function ownerKey(model: ModelSeed): string {
+ return (model.authors.find((a) => a.role === 'owner') ?? model.authors[0]!).user;
+}
+
+export async function loadModels(
+ models: ModelSeed[],
+ users: IdMap,
+ tags: IdMap,
+ uploader: AssetUploader,
+): Promise {
+ const modelIds: IdMap = new Map(models.map((m) => [m.key, seedId('model', m.key)]));
+ const loaded: LoadedModel[] = [];
+
+ for (const m of models) {
+ const id = modelIds.get(m.key)!;
+ const createdAt = daysAgo(m.createdDaysAgo ?? 365);
+ const deletedAt = m.deleted ? daysAgo(Math.max((m.createdDaysAgo ?? 365) - 1, 0)) : null;
+ const parentModelId = m.parent ? mustGet(modelIds, m.parent, 'parent model') : null;
+ const parentVersionNumber = m.parent ? (m.parentVersionNumber ?? 1) : null;
+
+ await prisma.model.upsert({
+ where: { id },
+ update: {},
+ create: {
+ id,
+ legacyId: m.legacyId ?? null,
+ visibility: m.visibility ?? 'public',
+ isEndorsed: m.isEndorsed ?? false,
+ isLibraryModel: m.isLibraryModel ?? false,
+ parentModelId,
+ parentVersionNumber,
+ viewCount: m.popularity?.views ?? 0,
+ runCount: m.popularity?.runs ?? 0,
+ downloadCount: m.popularity?.downloads ?? 0,
+ shareCount: m.popularity?.shares ?? 0,
+ createdAt,
+ deletedAt,
+ },
+ });
+
+ let versionNumber = 0;
+ for (const v of m.versions) {
+ versionNumber += 1;
+ const asset = resolveAsset(m, v, versionNumber);
+ await uploader.putNlogox(asset);
+
+ const versionCreatedAt = daysAgo(v.createdDaysAgo ?? m.createdDaysAgo ?? 365);
+
+ await prisma.modelVersion.upsert({
+ where: { modelId_versionNumber: { modelId: id, versionNumber } },
+ update: {},
+ create: {
+ modelId: id,
+ versionNumber,
+ title: v.title,
+ description: v.description ?? null,
+ netlogoFileKey: asset.key,
+ previewImageFileKey: asset.preview?.key ?? null,
+ netlogoVersion: v.netlogoVersion ?? null,
+ infoTab: v.infoTab ?? asset.infoTab ?? null,
+ createdAt: versionCreatedAt,
+ finalizedAt: versionCreatedAt,
+ },
+ });
+
+ for (const tagName of v.tags ?? []) {
+ const tagId = mustGet(tags, tagName, 'tag');
+ await prisma.modelVersionTag.upsert({
+ where: { modelId_versionNumber_tagId: { modelId: id, versionNumber, tagId } },
+ update: {},
+ create: { modelId: id, versionNumber, tagId },
+ });
+ }
+
+ for (const sf of v.supplementaryFiles ?? []) {
+ const fileAsset = textAsset(sf.filename, sf.content, `uploads/models/${m.key}`);
+ await uploader.putSupplementary(fileAsset);
+ const fileId = seedId('mvfile', m.key, versionNumber, sf.filename);
+ await prisma.modelVersionFile.upsert({
+ where: { id: fileId },
+ update: {},
+ create: { id: fileId, modelId: id, versionNumber, fileKey: fileAsset.key },
+ });
+ }
+ }
+
+ await prisma.model.update({ where: { id }, data: { latestVersionNumber: versionNumber } });
+
+ for (const af of m.additionalFiles ?? []) {
+ const fileAsset = textAsset(af.filename, af.content, `uploads/models/${m.key}/additional`);
+ await uploader.putSupplementary(fileAsset);
+ const fileId = seedId('addfile', m.key, af.filename);
+ await prisma.modelAdditionalFile.upsert({
+ where: { id: fileId },
+ update: {},
+ create: {
+ id: fileId,
+ modelId: id,
+ taggedVersionNumber: af.taggedVersionNumber,
+ fileKey: fileAsset.key,
+ },
+ });
+ }
+
+ for (const a of m.authors) {
+ const userId = mustGet(users, a.user, 'author');
+ await prisma.modelAuthor.upsert({
+ where: { modelId_userId: { modelId: id, userId } },
+ update: {},
+ create: { modelId: id, userId, role: a.role ?? 'contributor' },
+ });
+ }
+
+ for (const p of m.permissions ?? []) {
+ const granteeUserId = p.grantee ? mustGet(users, p.grantee, 'permission grantee') : null;
+ const permissionLevel = p.level ?? 'read';
+ // The (modelId, granteeUserId) unique can't be expressed in Prisma's
+ // compound-unique `where` when granteeUserId is null, so reconcile by hand
+ // - idempotent regardless of the existing row's id.
+ const existing = await prisma.modelPermission.findFirst({
+ where: { modelId: id, granteeUserId },
+ select: { id: true },
+ });
+ if (existing) {
+ await prisma.modelPermission.update({
+ where: { id: existing.id },
+ data: { permissionLevel },
+ });
+ } else {
+ await prisma.modelPermission.create({
+ data: { id: seedId('perm', m.key, p.grantee ?? 'public'), modelId: id, granteeUserId, permissionLevel },
+ });
+ }
+ }
+
+ loaded.push({
+ key: m.key,
+ id,
+ latestVersionNumber: versionNumber,
+ createdAt,
+ deletedAt,
+ owner: ownerKey(m),
+ popularity: m.popularity,
+ isFork: Boolean(m.parent),
+ parentModelId: parentModelId ?? undefined,
+ parentVersionNumber: parentVersionNumber ?? undefined,
+ versionTitles: m.versions.map((v) => v.title),
+ visibility: m.visibility ?? 'public',
+ });
+ }
+
+ return loaded;
+}
+
+export async function loadEngagement(
+ models: LoadedModel[],
+ users: IdMap,
+): Promise<{ likes: number; interactions: number; events: number }> {
+ const userIds = [...users.values()];
+
+ const likeRows: Array<{ modelId: string; userId: string; createdAt: Date }> = [];
+ const interactionRows: Array> = [];
+
+ for (const m of models) {
+ if (!m.popularity) continue;
+ const spanMs = Math.max(NOW - m.createdAt.getTime(), DAY);
+
+ const likeRng = seededRandom(`likes:${m.key}`);
+ for (const userKey of m.popularity.likedBy ?? []) {
+ likeRows.push({
+ modelId: m.id,
+ userId: mustGet(users, userKey, 'like author'),
+ createdAt: new Date(m.createdAt.getTime() + Math.floor(likeRng() * spanMs)),
+ });
+ }
+
+ const rng = seededRandom(`interactions:${m.key}`);
+ const kinds = [
+ ['view', m.popularity.views],
+ ['run', m.popularity.runs],
+ ['download', m.popularity.downloads],
+ ['share', m.popularity.shares],
+ ] as const;
+
+ for (const [kind, count] of kinds) {
+ for (let i = 0; i < count; i++) {
+ const attributed = rng() < 0.35 && userIds.length > 0;
+ const userId = attributed ? pick(rng, userIds) : null;
+ interactionRows.push({
+ id: seedId('interaction', m.key, kind, i),
+ modelId: m.id,
+ versionNumber: m.latestVersionNumber,
+ kind,
+ userId,
+ sessionId: null,
+ ipHash: userId
+ ? null
+ : createHash('sha256').update(`${m.key}:${kind}:${i}`).digest('hex').slice(0, 32),
+ userAgent: pick(rng, UA_POOL),
+ referer: pick(rng, REFERER_POOL),
+ geo: pick(rng, GEO_POOL),
+ createdAt: new Date(m.createdAt.getTime() + Math.floor(rng() * spanMs)),
+ });
+ }
+ }
+ }
+
+ if (likeRows.length) {
+ await prisma.modelLike.createMany({ data: likeRows, skipDuplicates: true });
+ }
+
+ for (let i = 0; i < interactionRows.length; i += 1000) {
+ const chunk = interactionRows.slice(i, i + 1000);
+ await prisma.modelInteraction.createMany({ data: chunk as never, skipDuplicates: true });
+ }
+
+ const events = buildEvents(models, users);
+ if (events.length) {
+ await prisma.event.createMany({ data: events as never, skipDuplicates: true });
+ }
+
+ return { likes: likeRows.length, interactions: interactionRows.length, events: events.length };
+}
+
+function buildEvents(models: LoadedModel[], users: IdMap) {
+ const events: Array> = [];
+
+ for (const m of models) {
+ const actorId = mustGet(users, m.owner, 'event actor');
+
+ events.push({
+ id: seedId('event', 'model.created', m.id),
+ type: 'model.created',
+ actorId,
+ resourceType: 'model',
+ resourceId: m.id,
+ payload: { title: m.versionTitles[0] ?? m.key, visibility: m.visibility },
+ createdAt: m.createdAt,
+ processedAt: m.createdAt,
+ });
+
+ if (m.isFork && m.parentModelId) {
+ events.push({
+ id: seedId('event', 'model.forked', m.id),
+ type: 'model.forked',
+ actorId,
+ resourceType: 'model',
+ resourceId: m.id,
+ payload: { parentModelId: m.parentModelId, parentVersionNumber: m.parentVersionNumber },
+ createdAt: m.createdAt,
+ processedAt: m.createdAt,
+ });
+ }
+
+ for (let n = 1; n <= m.latestVersionNumber; n++) {
+ events.push({
+ id: seedId('event', 'model_version.created', m.id, n),
+ type: 'model_version.created',
+ actorId,
+ resourceType: 'model_version',
+ resourceId: `${m.id}:${n}`,
+ payload: { modelId: m.id, versionNumber: n },
+ createdAt: m.createdAt,
+ processedAt: m.createdAt,
+ });
+ }
+
+ if (m.deletedAt) {
+ events.push({
+ id: seedId('event', 'model.deleted', m.id),
+ type: 'model.deleted',
+ actorId,
+ resourceType: 'model',
+ resourceId: m.id,
+ payload: { title: m.versionTitles[0] ?? m.key },
+ createdAt: m.deletedAt,
+ });
+ }
+ }
+
+ return events;
+}
+
+export async function loadDrafts(
+ drafts: DraftSeed[],
+ users: IdMap,
+ modelIds: IdMap,
+ uploader: AssetUploader,
+): Promise {
+ for (const d of drafts) {
+ const userId = mustGet(users, d.user, 'draft user');
+ const modelId = d.basedOnModel ? mustGet(modelIds, d.basedOnModel, 'draft model') : null;
+ const createdAt = daysAgo(d.createdDaysAgo ?? 7);
+
+ const data: Record = {};
+ if (d.title) data['title'] = d.title;
+ if (d.description) data['description'] = d.description;
+ if (d.visibility) data['visibility'] = d.visibility;
+ if (d.tags) data['tags'] = d.tags;
+
+ if (d.primaryFile) {
+ const asset = isRealFile(d.primaryFile)
+ ? loadNlogox(d.primaryFile.file, d.primaryFile.preview)
+ : fakeNlogox(`draft-${d.key}`, d.title ?? 'Untitled Draft', d.primaryFile.preview);
+ await uploader.putNlogox(asset);
+ data['primaryFile'] = {
+ s3Key: asset.key,
+ filename: asset.filename,
+ sizeBytes: Number(asset.sizeBytes),
+ mimeType: asset.contentType,
+ };
+ if (asset.preview) {
+ data['previewImage'] = {
+ s3Key: asset.preview.key,
+ filename: asset.preview.filename,
+ sizeBytes: asset.preview.blob.byteLength,
+ mimeType: asset.preview.contentType,
+ };
+ }
+ }
+
+ const id = seedId('draft', d.key);
+ await prisma.modelDraft.upsert({
+ where: { id },
+ update: {},
+ create: { id, userId, modelId, schemaVersion: 1, data: data as never, createdAt },
+ });
+ }
+
+ return drafts.length;
+}
diff --git a/apps/modeling-commons-backend/prisma/seed/manifest/drafts.ts b/apps/modeling-commons-backend/prisma/seed/manifest/drafts.ts
new file mode 100644
index 00000000..96664a2e
--- /dev/null
+++ b/apps/modeling-commons-backend/prisma/seed/manifest/drafts.ts
@@ -0,0 +1,45 @@
+import type { DraftSeed } from './types.js';
+
+export const DRAFTS: DraftSeed[] = [
+ {
+ key: 'priya-slime',
+ user: 'priya',
+ title: 'Slime Mold Aggregation',
+ description:
+ 'Draft model of Dictyostelium aggregation via cAMP signaling. Still tuning the chemotaxis parameters.',
+ visibility: 'public',
+ tags: ['biology', 'swarm-intelligence', 'emergence'],
+ primaryFile: { placeholder: true },
+ createdDaysAgo: 8,
+ },
+ {
+ key: 'diego-roundabout',
+ user: 'diego',
+ title: 'Roundabout Flow',
+ description: 'A draft exploring whether roundabouts beat signalized intersections at low volume.',
+ visibility: 'public',
+ tags: ['traffic', 'urban'],
+ primaryFile: { file: 'traffic-grid.nlogox', preview: 'traffic-grid-preview.png' },
+ createdDaysAgo: 5,
+ },
+ {
+ key: 'chen-markets',
+ user: 'chen',
+ title: 'Minority Game',
+ visibility: 'private',
+ tags: ['economics', 'game-theory'],
+ createdDaysAgo: 3,
+ },
+ {
+ // A draft that revises an already-published model (a pending new version).
+ key: 'amara-virus-net-v2',
+ user: 'amara',
+ basedOnModel: 'virus-network',
+ title: 'Virus on a Network',
+ description: 'Adding weighted edges and a quarantine policy toggle for version 2.',
+ visibility: 'public',
+ tags: ['network', 'epidemiology'],
+ primaryFile: { placeholder: true },
+ createdDaysAgo: 2,
+ },
+];
diff --git a/apps/modeling-commons-backend/prisma/seed/manifest/index.ts b/apps/modeling-commons-backend/prisma/seed/manifest/index.ts
new file mode 100644
index 00000000..c6bbed09
--- /dev/null
+++ b/apps/modeling-commons-backend/prisma/seed/manifest/index.ts
@@ -0,0 +1,5 @@
+export * from './types.js';
+export { USERS } from './users.js';
+export { TAGS } from './tags.js';
+export { MODELS } from './models.js';
+export { DRAFTS } from './drafts.js';
diff --git a/apps/modeling-commons-backend/prisma/seed/manifest/models.ts b/apps/modeling-commons-backend/prisma/seed/manifest/models.ts
new file mode 100644
index 00000000..9fdc1894
--- /dev/null
+++ b/apps/modeling-commons-backend/prisma/seed/manifest/models.ts
@@ -0,0 +1,558 @@
+import type { ModelSeed } from './types.js';
+
+const CSV = (header: string, rows: string) => `${header}\n${rows}`;
+
+export const MODELS: ModelSeed[] = [
+ {
+ key: 'wolf-sheep',
+ legacyId: 1001,
+ isEndorsed: true,
+ isLibraryModel: true,
+ createdDaysAgo: 700,
+ authors: [
+ { user: 'uri', role: 'owner' },
+ { user: 'seth', role: 'contributor' },
+ ],
+ permissions: [{ grantee: 'maria', level: 'write' }],
+ versions: [
+ {
+ title: 'Wolf Sheep Predation',
+ description:
+ 'A classic predator–prey model exploring the population dynamics between wolves, sheep, and the grass they graze on.',
+ netlogoVersion: '7.0.0',
+ tags: ['ecology', 'predator-prey', 'biology'],
+ file: { file: 'wolf-sheep-predation.nlogox', preview: 'wolf-sheep-preview.png' },
+ createdDaysAgo: 700,
+ supplementaryFiles: [
+ {
+ filename: 'initial-populations.csv',
+ content: CSV('tick,wolves,sheep', '0,50,100\n1,48,105\n2,45,112'),
+ },
+ ],
+ },
+ {
+ title: 'Wolf Sheep Predation',
+ description:
+ 'Updated with energy-based movement and grass regrowth mechanics for more realistic equilibria.',
+ netlogoVersion: '7.0.3',
+ tags: ['ecology', 'predator-prey', 'biology', 'emergence'],
+ file: { file: 'wolf-sheep-predation-v2.nlogox', preview: 'wolf-sheep-preview.png' },
+ createdDaysAgo: 210,
+ },
+ ],
+ additionalFiles: [
+ {
+ taggedVersionNumber: 1,
+ filename: 'README.md',
+ content: '# Wolf Sheep Predation\n\nA classic predator-prey model.',
+ },
+ ],
+ popularity: {
+ views: 612,
+ runs: 248,
+ downloads: 96,
+ shares: 34,
+ likedBy: ['maria', 'kenji', 'priya', 'diego', 'amara', 'liam', 'chen'],
+ },
+ },
+ {
+ key: 'wolf-sheep-seasonal',
+ parent: 'wolf-sheep',
+ parentVersionNumber: 2,
+ createdDaysAgo: 120,
+ authors: [{ user: 'maria', role: 'owner' }],
+ versions: [
+ {
+ title: 'Wolf Sheep – Seasonal Variant',
+ description:
+ 'A fork of Wolf Sheep Predation that adds seasonal grass-growth patterns, producing boom-and-bust cycles.',
+ netlogoVersion: '7.0.0',
+ tags: ['ecology', 'predator-prey', 'climate'],
+ file: { file: 'wolf-sheep-predation-fork.nlogox', preview: 'wolf-sheep-preview.png' },
+ },
+ ],
+ popularity: { views: 143, runs: 61, downloads: 18, shares: 5, likedBy: ['uri', 'kenji'] },
+ },
+ {
+ key: 'wolf-sheep-grass',
+ parent: 'wolf-sheep',
+ parentVersionNumber: 2,
+ createdDaysAgo: 60,
+ authors: [{ user: 'kenji', role: 'owner' }],
+ versions: [
+ {
+ title: 'Wolf Sheep – Classroom Edition',
+ description:
+ 'A simplified fork used in a high-school biology class to introduce carrying capacity.',
+ netlogoVersion: '7.0.3',
+ tags: ['ecology', 'predator-prey', 'education', 'beginner'],
+ file: { placeholder: true, preview: 'wolf-sheep-preview.png' },
+ },
+ ],
+ popularity: { views: 87, runs: 40, downloads: 12, shares: 2, likedBy: ['maria'] },
+ },
+
+ {
+ key: 'fire',
+ legacyId: 1002,
+ isEndorsed: true,
+ isLibraryModel: true,
+ createdDaysAgo: 680,
+ authors: [{ user: 'seth', role: 'owner' }],
+ permissions: [{ grantee: 'uri', level: 'admin' }],
+ versions: [
+ {
+ title: 'Fire',
+ description:
+ "Simulates the spread of fire through a forest. The fire's chance of reaching the far edge depends critically on tree density — a classic example of a non-linear phase transition.",
+ netlogoVersion: '6.4.0',
+ tags: ['fire', 'emergence', 'physics'],
+ file: { file: 'fire.nlogox', preview: 'fire-preview.png' },
+ },
+ ],
+ additionalFiles: [
+ {
+ taggedVersionNumber: 1,
+ filename: 'burn-results.csv',
+ content: CSV('density,burned_pct', '50,18\n59,52\n65,86\n75,98'),
+ },
+ ],
+ popularity: {
+ views: 504,
+ runs: 301,
+ downloads: 74,
+ shares: 21,
+ likedBy: ['uri', 'kenji', 'diego', 'fatima', 'maria'],
+ },
+ },
+ {
+ key: 'fire-big',
+ parent: 'fire',
+ parentVersionNumber: 1,
+ createdDaysAgo: 45,
+ authors: [{ user: 'kenji', role: 'owner' }],
+ versions: [
+ {
+ title: 'Fire – Large Forests',
+ description:
+ 'A fork that scales the world up and adds wind direction to study fire-front behavior at larger scales.',
+ netlogoVersion: '7.0.0',
+ tags: ['fire', 'emergence', 'climate'],
+ file: { placeholder: true, preview: 'fire-preview.png' },
+ },
+ ],
+ popularity: { views: 66, runs: 33, downloads: 9, shares: 1, likedBy: ['seth'] },
+ },
+
+ {
+ key: 'ants',
+ legacyId: 1003,
+ isEndorsed: true,
+ isLibraryModel: true,
+ createdDaysAgo: 660,
+ authors: [{ user: 'uri', role: 'owner' }],
+ versions: [
+ {
+ title: 'Ants',
+ description:
+ 'A colony of ants forages for food. Each ant follows simple pheromone rules, yet the colony as a whole finds the shortest paths to food — emergent optimization.',
+ netlogoVersion: '6.3.0',
+ tags: ['biology', 'swarm-intelligence', 'emergence'],
+ file: { file: 'ants.nlogox', preview: 'ants-preview.png' },
+ },
+ ],
+ popularity: {
+ views: 458,
+ runs: 192,
+ downloads: 63,
+ shares: 19,
+ likedBy: ['priya', 'kenji', 'maria', 'diego'],
+ },
+ },
+
+ {
+ key: 'traffic-basic',
+ legacyId: 1004,
+ isLibraryModel: true,
+ createdDaysAgo: 520,
+ authors: [{ user: 'uri', role: 'owner' }],
+ versions: [
+ {
+ title: 'Traffic Basic',
+ description:
+ 'A single lane of cars on a circular road. A single slow-down can cascade into a phantom traffic jam that travels backward through the line of cars.',
+ netlogoVersion: '6.4.0',
+ tags: ['traffic', 'emergence', 'beginner'],
+ file: { file: 'traffic-basic.nlogox', preview: 'traffic-basic-preview.png' },
+ },
+ ],
+ popularity: {
+ views: 388,
+ runs: 156,
+ downloads: 41,
+ shares: 12,
+ likedBy: ['diego', 'liam', 'kenji'],
+ },
+ },
+ {
+ key: 'traffic-grid',
+ legacyId: 1005,
+ isLibraryModel: true,
+ createdDaysAgo: 360,
+ authors: [{ user: 'uri', role: 'owner' }],
+ versions: [
+ {
+ title: 'Traffic Grid',
+ description:
+ 'Cars move through a grid of intersections governed by traffic lights. Tune light timing to minimize average wait time across the city.',
+ netlogoVersion: '7.0.0',
+ tags: ['traffic', 'urban', 'emergence'],
+ file: { file: 'traffic-grid.nlogox', preview: 'traffic-grid-preview.png' },
+ },
+ ],
+ popularity: { views: 271, runs: 118, downloads: 35, shares: 9, likedBy: ['diego', 'liam'] },
+ },
+ {
+ key: 'traffic-2-lanes',
+ parent: 'traffic-basic',
+ parentVersionNumber: 1,
+ createdDaysAgo: 150,
+ authors: [{ user: 'diego', role: 'owner' }],
+ permissions: [{ grantee: null, level: 'read' }],
+ versions: [
+ {
+ title: 'Traffic – 2 Lanes',
+ description:
+ 'Extends Traffic Basic to two lanes with lane-changing, exploring how merging behavior affects throughput.',
+ netlogoVersion: '7.0.0',
+ tags: ['traffic', 'urban', 'emergence'],
+ file: { file: 'traffic-2-lanes.nlogox', preview: 'traffic-2-lanes-preview.png' },
+ },
+ ],
+ popularity: { views: 132, runs: 58, downloads: 14, shares: 3, likedBy: ['liam'] },
+ },
+
+ {
+ key: 'virus-network',
+ legacyId: 1006,
+ isLibraryModel: true,
+ createdDaysAgo: 300,
+ authors: [
+ { user: 'amara', role: 'owner' },
+ { user: 'uri', role: 'contributor' },
+ ],
+ versions: [
+ {
+ title: 'Virus on a Network',
+ description:
+ 'Models how a virus spreads through a network of connected nodes, and how vaccination and recovery rates change the outcome.',
+ netlogoVersion: '6.4.0',
+ tags: ['network', 'epidemiology', 'biology'],
+ file: { placeholder: true },
+ },
+ ],
+ popularity: {
+ views: 297,
+ runs: 134,
+ downloads: 48,
+ shares: 16,
+ likedBy: ['amara', 'maria', 'liam', 'fatima'],
+ },
+ },
+ {
+ key: 'virus',
+ isLibraryModel: true,
+ createdDaysAgo: 280,
+ authors: [{ user: 'amara', role: 'owner' }],
+ versions: [
+ {
+ title: 'Virus',
+ description:
+ 'A well-mixed population model of infection, recovery, and immunity — the spatial counterpart to Virus on a Network.',
+ netlogoVersion: '6.4.0',
+ tags: ['epidemiology', 'biology'],
+ file: { placeholder: true },
+ },
+ ],
+ popularity: { views: 188, runs: 79, downloads: 22, shares: 6, likedBy: ['fatima', 'maria'] },
+ },
+ {
+ key: 'rumor-mill',
+ createdDaysAgo: 90,
+ authors: [{ user: 'fatima', role: 'owner' }],
+ versions: [
+ {
+ title: 'Rumor Mill',
+ description:
+ 'How does a rumor propagate through a social network? Compare broadcast, word-of-mouth, and influencer-seeded spreading.',
+ netlogoVersion: '7.0.0',
+ tags: ['network', 'social-science'],
+ file: { placeholder: true },
+ },
+ ],
+ popularity: { views: 74, runs: 28, downloads: 7, shares: 4, likedBy: ['liam'] },
+ },
+
+ {
+ key: 'flocking',
+ isEndorsed: true,
+ isLibraryModel: true,
+ createdDaysAgo: 640,
+ authors: [{ user: 'uri', role: 'owner' }],
+ versions: [
+ {
+ title: 'Flocking',
+ description:
+ 'Birds follow three simple rules — alignment, separation, and cohesion — and coherent flocks emerge with no leader.',
+ netlogoVersion: '6.4.0',
+ tags: ['biology', 'swarm-intelligence', 'emergence'],
+ file: { placeholder: true, preview: 'ants-preview.png' },
+ },
+ ],
+ popularity: {
+ views: 421,
+ runs: 173,
+ downloads: 57,
+ shares: 18,
+ likedBy: ['priya', 'maria', 'kenji', 'diego'],
+ },
+ },
+ {
+ key: 'termites',
+ isLibraryModel: true,
+ createdDaysAgo: 250,
+ authors: [{ user: 'priya', role: 'owner' }],
+ versions: [
+ {
+ title: 'Termites',
+ description:
+ 'Termites pile up wood chips into a single mound by following purely local rules — stigmergy in action.',
+ netlogoVersion: '6.3.0',
+ tags: ['biology', 'swarm-intelligence', 'emergence'],
+ file: { placeholder: true, preview: 'ants-preview.png' },
+ },
+ ],
+ popularity: { views: 165, runs: 72, downloads: 19, shares: 5, likedBy: ['priya', 'uri'] },
+ },
+
+ // ── Cellular automata & math ──────────────────────────────────────────────
+ {
+ key: 'game-of-life',
+ isEndorsed: true,
+ isLibraryModel: true,
+ createdDaysAgo: 600,
+ authors: [{ user: 'seth', role: 'owner' }],
+ versions: [
+ {
+ title: 'Life',
+ description:
+ "Conway's Game of Life — the canonical cellular automaton. Gliders, blinkers, and still lifes from two simple birth/death rules.",
+ netlogoVersion: '6.4.0',
+ tags: ['cellular-automata', 'mathematics', 'emergence'],
+ file: { placeholder: true },
+ },
+ ],
+ popularity: {
+ views: 356,
+ runs: 201,
+ downloads: 44,
+ shares: 13,
+ likedBy: ['maria', 'chen', 'kenji'],
+ },
+ },
+ {
+ key: 'diffusion',
+ isLibraryModel: true,
+ createdDaysAgo: 470,
+ authors: [{ user: 'seth', role: 'owner' }],
+ versions: [
+ {
+ title: 'Diffusion Graphics',
+ description:
+ 'Watch a substance diffuse across a grid, illustrating how local averaging produces smooth global gradients.',
+ netlogoVersion: '6.4.0',
+ tags: ['physics', 'chemistry', 'mathematics'],
+ file: { placeholder: true },
+ },
+ ],
+ popularity: { views: 122, runs: 54, downloads: 11, shares: 2, likedBy: ['maria'] },
+ },
+
+ {
+ key: 'segregation',
+ isEndorsed: true,
+ isLibraryModel: true,
+ createdDaysAgo: 580,
+ authors: [{ user: 'uri', role: 'owner' }],
+ versions: [
+ {
+ title: 'Segregation',
+ description:
+ "Schelling's model of segregation: even a mild preference to live near similar neighbors tips a mixed neighborhood into stark segregation.",
+ netlogoVersion: '6.4.0',
+ tags: ['social-science', 'segregation', 'emergence'],
+ file: { placeholder: true, preview: 'traffic-grid-preview.png' },
+ },
+ ],
+ popularity: {
+ views: 398,
+ runs: 142,
+ downloads: 61,
+ shares: 22,
+ likedBy: ['liam', 'chen', 'maria', 'fatima'],
+ },
+ },
+ {
+ key: 'wealth-distribution',
+ createdDaysAgo: 110,
+ authors: [{ user: 'chen', role: 'owner' }],
+ permissions: [{ grantee: 'liam', level: 'read' }],
+ versions: [
+ {
+ title: 'Wealth Distribution',
+ description:
+ 'Agents harvest and trade on a grain landscape. Starting from equality, a skewed wealth distribution emerges and persists.',
+ netlogoVersion: '7.0.0',
+ tags: ['economics', 'social-science', 'emergence'],
+ file: { placeholder: true },
+ },
+ ],
+ popularity: { views: 96, runs: 38, downloads: 13, shares: 4, likedBy: ['chen', 'liam'] },
+ },
+ {
+ key: 'el-farol',
+ isLibraryModel: true,
+ createdDaysAgo: 230,
+ authors: [{ user: 'chen', role: 'owner' }],
+ versions: [
+ {
+ title: 'El Farol',
+ description:
+ 'A bounded-rationality classic: bar-goers use competing strategies to predict the crowd, and attendance hovers around capacity.',
+ netlogoVersion: '6.4.0',
+ tags: ['economics', 'game-theory', 'complexity'],
+ file: { placeholder: true },
+ },
+ ],
+ popularity: { views: 141, runs: 49, downloads: 16, shares: 5, likedBy: ['chen'] },
+ },
+ {
+ key: 'pd-n-person',
+ isLibraryModel: true,
+ createdDaysAgo: 320,
+ authors: [{ user: 'liam', role: 'owner' }],
+ versions: [
+ {
+ title: 'PD N-Person Iterated',
+ description:
+ 'An N-person iterated Prisoner’s Dilemma. Watch cooperation rise and fall as strategies meet repeatedly.',
+ netlogoVersion: '6.4.0',
+ tags: ['game-theory', 'social-science', 'evolution'],
+ file: { placeholder: true },
+ },
+ ],
+ popularity: { views: 177, runs: 83, downloads: 24, shares: 8, likedBy: ['liam', 'chen'] },
+ },
+ {
+ key: 'heroes-cowards',
+ createdDaysAgo: 70,
+ authors: [{ user: 'liam', role: 'owner' }],
+ versions: [
+ {
+ title: 'Heroes and Cowards',
+ description:
+ 'Every agent secretly picks a friend and an enemy, then either hides behind their friend (coward) or steps between friend and enemy (hero). Two rules, wildly different patterns.',
+ netlogoVersion: '7.0.0',
+ tags: ['social-science', 'emergence', 'beginner'],
+ file: { placeholder: true },
+ },
+ ],
+ popularity: { views: 109, runs: 62, downloads: 10, shares: 6, likedBy: ['kenji', 'diego'] },
+ },
+
+ {
+ key: 'ising',
+ isLibraryModel: true,
+ createdDaysAgo: 200,
+ authors: [{ user: 'maria', role: 'owner' }],
+ versions: [
+ {
+ title: 'Ising',
+ description:
+ 'The Ising model of ferromagnetism. Sweep the temperature through the critical point and watch spontaneous magnetization appear.',
+ netlogoVersion: '6.4.0',
+ tags: ['physics', 'emergence', 'complexity'],
+ file: { placeholder: true },
+ },
+ ],
+ popularity: { views: 134, runs: 71, downloads: 17, shares: 3, likedBy: ['maria', 'seth'] },
+ },
+ {
+ key: 'daisyworld',
+ isLibraryModel: true,
+ createdDaysAgo: 260,
+ authors: [{ user: 'maria', role: 'owner' }],
+ versions: [
+ {
+ title: 'Daisyworld',
+ description:
+ 'Black and white daisies regulate the temperature of an imaginary planet, a vivid illustration of the Gaia hypothesis.',
+ netlogoVersion: '6.4.0',
+ tags: ['climate', 'ecology', 'biology'],
+ file: { placeholder: true },
+ },
+ ],
+ popularity: { views: 158, runs: 64, downloads: 20, shares: 7, likedBy: ['maria', 'kenji'] },
+ },
+
+ {
+ key: 'forest-succession',
+ visibility: 'private',
+ createdDaysAgo: 25,
+ authors: [{ user: 'maria', role: 'owner' }],
+ permissions: [{ grantee: 'uri', level: 'read' }],
+ versions: [
+ {
+ title: 'Forest Succession (WIP)',
+ description:
+ 'Unpublished work in progress: modeling how a forest recovers after disturbance through species succession.',
+ netlogoVersion: '7.0.0',
+ tags: ['ecology', 'evolution'],
+ file: { placeholder: true },
+ },
+ ],
+ popularity: { views: 12, runs: 3, downloads: 0, shares: 0, likedBy: [] },
+ },
+ {
+ key: 'sophie-sandbox',
+ visibility: 'unlisted',
+ createdDaysAgo: 40,
+ authors: [{ user: 'sophie', role: 'owner' }],
+ versions: [
+ {
+ title: 'Diffusion Experiments',
+ description:
+ 'Unlisted scratchpad for course assignments — shareable by link but hidden from search.',
+ netlogoVersion: '7.0.0',
+ tags: ['physics', 'education'],
+ file: { placeholder: true },
+ },
+ ],
+ popularity: { views: 31, runs: 9, downloads: 2, shares: 1, likedBy: [] },
+ },
+ {
+ key: 'noah-deleted',
+ deleted: true,
+ createdDaysAgo: 20,
+ authors: [{ user: 'noah', role: 'owner' }],
+ versions: [
+ {
+ title: 'Untitled Test Model',
+ description: 'A test model the author later deleted.',
+ netlogoVersion: '7.0.0',
+ tags: ['beginner'],
+ file: { placeholder: true },
+ },
+ ],
+ },
+];
diff --git a/apps/modeling-commons-backend/prisma/seed/manifest/tags.ts b/apps/modeling-commons-backend/prisma/seed/manifest/tags.ts
new file mode 100644
index 00000000..73452a0c
--- /dev/null
+++ b/apps/modeling-commons-backend/prisma/seed/manifest/tags.ts
@@ -0,0 +1,28 @@
+import type { TagSeed } from './types.js';
+
+export const TAGS: TagSeed[] = [
+ { name: 'ecology', displayName: 'Ecology', legacyId: 101 },
+ { name: 'predator-prey', displayName: 'Predator–Prey', legacyId: 102 },
+ { name: 'biology', displayName: 'Biology', legacyId: 103 },
+ { name: 'emergence', displayName: 'Emergence', legacyId: 104 },
+ { name: 'fire', displayName: 'Fire', legacyId: 105 },
+ { name: 'physics', displayName: 'Physics' },
+ { name: 'chemistry', displayName: 'Chemistry' },
+ { name: 'network', displayName: 'Networks' },
+ { name: 'epidemiology', displayName: 'Epidemiology' },
+ { name: 'swarm-intelligence', displayName: 'Swarm Intelligence' },
+ { name: 'social-science', displayName: 'Social Science' },
+ { name: 'economics', displayName: 'Economics' },
+ { name: 'game-theory', displayName: 'Game Theory' },
+ { name: 'traffic', displayName: 'Traffic' },
+ { name: 'urban', displayName: 'Urban Dynamics' },
+ { name: 'cellular-automata', displayName: 'Cellular Automata' },
+ { name: 'mathematics', displayName: 'Mathematics' },
+ { name: 'climate', displayName: 'Climate' },
+ { name: 'evolution', displayName: 'Evolution' },
+ { name: 'segregation', displayName: 'Segregation' },
+ { name: 'education', displayName: 'Education' },
+ { name: 'beginner', displayName: 'Beginner Friendly' },
+ { name: 'complexity', displayName: 'Complexity' },
+ { name: 'agent-based', displayName: 'Agent-Based' },
+];
diff --git a/apps/modeling-commons-backend/prisma/seed/manifest/types.ts b/apps/modeling-commons-backend/prisma/seed/manifest/types.ts
new file mode 100644
index 00000000..6e8e279d
--- /dev/null
+++ b/apps/modeling-commons-backend/prisma/seed/manifest/types.ts
@@ -0,0 +1,126 @@
+export type SystemRoleLit = 'admin' | 'moderator' | 'user';
+export type UserKindLit = 'student' | 'teacher' | 'researcher' | 'other';
+export type VisibilityLit = 'public' | 'private' | 'unlisted';
+export type AuthorRoleLit = 'owner' | 'contributor';
+export type PermissionLevelLit = 'read' | 'write' | 'admin';
+
+export interface SocialLinkSeed {
+ rawValue: string;
+ type: 'twitter' | 'linkedin' | 'github' | 'facebook' | 'instagram' | 'website' | 'other';
+}
+
+export interface UserSeed {
+ /** Stable slug used to derive the user id and cross-reference from models. */
+ key: string;
+ legacyId?: number;
+ name: string;
+ email: string;
+ emailVerified?: boolean;
+ systemRole?: SystemRoleLit;
+ userKind?: UserKindLit;
+ isProfilePublic?: boolean;
+ bio?: string;
+ country?: string;
+ affiliation?: string;
+ socialLinks?: SocialLinkSeed[];
+ dob?: string;
+ onboarded?: boolean;
+ createdDaysAgo?: number;
+ /** Mint a stable dev session token (`dev-session-`) for local sign-in. */
+ devSession?: boolean;
+}
+
+export interface TagSeed {
+ name: string;
+ displayName: string;
+ legacyId?: number;
+}
+
+/** A model file backed by a real `.nlogox` shipped in `seed-files/`. */
+export interface RealFileSeed {
+ file: string;
+ preview?: string;
+}
+
+/** A synthesized placeholder model file (no real `.nlogox` shipped). */
+export interface PlaceholderFileSeed {
+ placeholder: true;
+ preview?: string;
+}
+
+export type ModelFileSeed = RealFileSeed | PlaceholderFileSeed;
+
+export function isRealFile(f: ModelFileSeed): f is RealFileSeed {
+ return 'file' in f;
+}
+
+export interface TextFileSeed {
+ filename: string;
+ content: string;
+}
+
+export interface VersionSeed {
+ title: string;
+ description?: string;
+ netlogoVersion?: string;
+ infoTab?: string;
+ tags?: string[];
+ file: ModelFileSeed;
+ supplementaryFiles?: TextFileSeed[];
+ createdDaysAgo?: number;
+}
+
+export interface ModelAuthorSeed {
+ user: string;
+ role?: AuthorRoleLit;
+}
+
+export interface ModelPermissionSeed {
+ /** `null` grants the permission to everyone (anonymous). */
+ grantee: string | null;
+ level?: PermissionLevelLit;
+}
+
+export interface PopularitySeed {
+ views: number;
+ runs: number;
+ downloads: number;
+ shares: number;
+ likedBy?: string[];
+}
+
+export interface AdditionalFileSeed {
+ taggedVersionNumber: number;
+ filename: string;
+ content: string;
+}
+
+export interface ModelSeed {
+ key: string;
+ legacyId?: number;
+ visibility?: VisibilityLit;
+ isEndorsed?: boolean;
+ isLibraryModel?: boolean;
+ parent?: string;
+ parentVersionNumber?: number;
+ deleted?: boolean;
+ createdDaysAgo?: number;
+ authors: ModelAuthorSeed[];
+ permissions?: ModelPermissionSeed[];
+ versions: VersionSeed[];
+ additionalFiles?: AdditionalFileSeed[];
+ popularity?: PopularitySeed;
+}
+
+export interface DraftSeed {
+ key: string;
+ user: string;
+ /** Existing model this draft revises (a new version), if any. */
+ basedOnModel?: string;
+ title?: string;
+ description?: string;
+ visibility?: VisibilityLit;
+ tags?: string[];
+ primaryFile?: ModelFileSeed;
+ createdDaysAgo?: number;
+}
diff --git a/apps/modeling-commons-backend/prisma/seed/manifest/users.ts b/apps/modeling-commons-backend/prisma/seed/manifest/users.ts
new file mode 100644
index 00000000..609d2ca4
--- /dev/null
+++ b/apps/modeling-commons-backend/prisma/seed/manifest/users.ts
@@ -0,0 +1,178 @@
+import type { UserSeed } from './types.js';
+
+export const USERS: UserSeed[] = [
+ {
+ key: 'uri',
+ legacyId: 1,
+ name: 'Uri Wilensky',
+ email: 'uri@example.com',
+ userKind: 'researcher',
+ isProfilePublic: true,
+ affiliation:
+ 'Center for Connected Learning and Computer-Based Modeling, Northwestern University',
+ country: 'US',
+ bio: 'Creator of NetLogo. Researching agent-based modeling and complexity in education for two decades.',
+ socialLinks: [
+ { rawValue: 'https://ccl.northwestern.edu', type: 'website' },
+ { rawValue: 'uri_wilensky', type: 'twitter' },
+ ],
+ createdDaysAgo: 900,
+ devSession: true,
+ },
+ {
+ key: 'seth',
+ legacyId: 2,
+ name: 'Seth Tisue',
+ email: 'seth@example.com',
+ userKind: 'researcher',
+ isProfilePublic: true,
+ affiliation: 'NetLogo Team, Northwestern University',
+ country: 'US',
+ bio: 'Maintainer and engineer on the NetLogo platform. Scala and functional programming enthusiast.',
+ socialLinks: [{ rawValue: 'SethTisue', type: 'github' }],
+ createdDaysAgo: 820,
+ devSession: true,
+ },
+ {
+ key: 'maria',
+ name: 'Maria Hernández',
+ email: 'maria@example.com',
+ userKind: 'researcher',
+ isProfilePublic: true,
+ affiliation: 'Santa Fe Institute',
+ country: 'ES',
+ bio: 'Complexity scientist studying emergence in ecological and social systems.',
+ socialLinks: [
+ { rawValue: 'maria-hernandez', type: 'linkedin' },
+ { rawValue: 'mh_complexity', type: 'twitter' },
+ ],
+ dob: '1985-04-12',
+ createdDaysAgo: 540,
+ },
+ {
+ key: 'kenji',
+ name: 'Kenji Watanabe',
+ email: 'kenji@example.com',
+ userKind: 'teacher',
+ isProfilePublic: true,
+ affiliation: 'Tokyo Metropolitan High School',
+ country: 'JP',
+ bio: 'High-school physics teacher using NetLogo to teach emergence and systems thinking.',
+ createdDaysAgo: 410,
+ },
+ {
+ key: 'amara',
+ name: 'Amara Okafor',
+ email: 'amara@example.com',
+ userKind: 'researcher',
+ isProfilePublic: true,
+ affiliation: 'University of Lagos, Department of Epidemiology',
+ country: 'NG',
+ bio: 'Epidemiologist modeling disease spread on contact networks.',
+ socialLinks: [{ rawValue: 'amaraokafor', type: 'github' }],
+ createdDaysAgo: 360,
+ },
+ {
+ key: 'liam',
+ name: 'Liam O’Connor',
+ email: 'liam@example.com',
+ userKind: 'teacher',
+ isProfilePublic: true,
+ affiliation: 'Trinity College Dublin',
+ country: 'IE',
+ bio: 'Lecturer in computational social science.',
+ createdDaysAgo: 300,
+ },
+ {
+ key: 'priya',
+ name: 'Priya Nair',
+ email: 'priya@example.com',
+ userKind: 'researcher',
+ isProfilePublic: true,
+ affiliation: 'Indian Institute of Science, Bangalore',
+ country: 'IN',
+ bio: 'Studying swarm intelligence and collective animal behavior.',
+ socialLinks: [{ rawValue: 'priya_nair', type: 'twitter' }],
+ createdDaysAgo: 280,
+ },
+ {
+ key: 'diego',
+ name: 'Diego Fernández',
+ email: 'diego@example.com',
+ userKind: 'student',
+ isProfilePublic: true,
+ affiliation: 'Universidad de Buenos Aires',
+ country: 'AR',
+ bio: 'Grad student exploring traffic and urban dynamics models.',
+ createdDaysAgo: 200,
+ },
+ {
+ key: 'sophie',
+ name: 'Sophie Laurent',
+ email: 'sophie@example.com',
+ userKind: 'student',
+ isProfilePublic: false,
+ affiliation: 'Sorbonne Université',
+ country: 'FR',
+ createdDaysAgo: 150,
+ },
+ {
+ key: 'noah',
+ name: 'Noah Schmidt',
+ email: 'noah@example.com',
+ userKind: 'student',
+ isProfilePublic: false,
+ country: 'DE',
+ onboarded: false,
+ emailVerified: false,
+ createdDaysAgo: 30,
+ },
+ {
+ key: 'fatima',
+ name: 'Fatima Al-Sayed',
+ email: 'fatima@example.com',
+ userKind: 'teacher',
+ isProfilePublic: true,
+ affiliation: 'American University of Beirut',
+ country: 'LB',
+ bio: 'Teaching introductory modeling to undergraduates.',
+ createdDaysAgo: 120,
+ },
+ {
+ key: 'chen',
+ name: 'Chen Wei',
+ email: 'chen@example.com',
+ userKind: 'researcher',
+ isProfilePublic: true,
+ affiliation: 'Tsinghua University',
+ country: 'CN',
+ bio: 'Economist interested in agent-based models of markets and wealth distribution.',
+ createdDaysAgo: 95,
+ },
+ {
+ key: 'mod-grace',
+ legacyId: 47,
+ name: 'Grace Mwangi',
+ email: 'grace@example.com',
+ systemRole: 'moderator',
+ userKind: 'other',
+ isProfilePublic: true,
+ affiliation: 'Modeling Commons',
+ country: 'KE',
+ bio: 'Community moderator keeping the commons tidy.',
+ createdDaysAgo: 600,
+ devSession: true,
+ },
+ {
+ key: 'admin',
+ legacyId: 99,
+ name: 'Site Admin',
+ email: 'admin@example.com',
+ systemRole: 'admin',
+ userKind: 'other',
+ isProfilePublic: false,
+ affiliation: 'Modeling Commons',
+ createdDaysAgo: 1000,
+ devSession: true,
+ },
+];
diff --git a/apps/modeling-commons-backend/prisma/seed/providers.ts b/apps/modeling-commons-backend/prisma/seed/providers.ts
index bc948537..9fedb3b2 100644
--- a/apps/modeling-commons-backend/prisma/seed/providers.ts
+++ b/apps/modeling-commons-backend/prisma/seed/providers.ts
@@ -1,7 +1,7 @@
import 'dotenv/config';
import { S3Client } from '@aws-sdk/client-s3';
import { PrismaPg } from '@prisma/adapter-pg';
-import { PrismaClient } from '../generated/prisma/client.js';
+import { PrismaClient } from '../../generated/prisma/client.js';
const adapter = new PrismaPg({ connectionString: process.env['DATABASE_URL']! });
export const prisma = new PrismaClient({ adapter });
From fecb71e7c332cf608c811ea53ddc31558788c1f2 Mon Sep 17 00:00:00 2001
From: Omar Ibrahim
Date: Thu, 18 Jun 2026 14:08:15 -0500
Subject: [PATCH 052/101] Bugfix(modeling-commons): Fix double file open dialog
bug on upload
---
.../app/components/upload/FileUploader.vue | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/apps/modeling-commons-frontend/app/components/upload/FileUploader.vue b/apps/modeling-commons-frontend/app/components/upload/FileUploader.vue
index c17e70be..c7968be2 100644
--- a/apps/modeling-commons-frontend/app/components/upload/FileUploader.vue
+++ b/apps/modeling-commons-frontend/app/components/upload/FileUploader.vue
@@ -15,7 +15,7 @@
variant="outline"
size="sm"
class="w-full justify-center"
- @click="open()"
+ @click.stop="open()"
/>
From 04530414c36d58ef41ba05f01aa7dadec56896ff Mon Sep 17 00:00:00 2001
From: Omar Ibrahim
Date: Thu, 18 Jun 2026 14:16:08 -0500
Subject: [PATCH 053/101] Bugfix(modeling-commons): Fix twitchy search bar in
models page
---
.../app/pages/models/index.vue | 28 +++++++++++--------
1 file changed, 16 insertions(+), 12 deletions(-)
diff --git a/apps/modeling-commons-frontend/app/pages/models/index.vue b/apps/modeling-commons-frontend/app/pages/models/index.vue
index 6ecdd100..90e3bc4a 100644
--- a/apps/modeling-commons-frontend/app/pages/models/index.vue
+++ b/apps/modeling-commons-frontend/app/pages/models/index.vue
@@ -4,11 +4,7 @@
-
+
@@ -230,13 +226,21 @@ watch(pending, (isLoading) => {
else indicator.finish();
});
-let keywordTimeout: ReturnType
;
-function onKeywordChange(value: string | number) {
- clearTimeout(keywordTimeout);
- keywordTimeout = setTimeout(() => {
- void setFilter("keyword", String(value));
- }, modelKeywordDebounceMs);
-}
+const { query: keyword, debouncedQuery: debouncedKeyword } = useSearchQuery({
+ defaultValue: filters.value.keyword ?? "",
+ debounce: { ms: modelKeywordDebounceMs },
+});
+
+watch(debouncedKeyword, (value) => {
+ void setFilter("keyword", value ?? "");
+});
+
+watch(
+ () => filters.value.keyword,
+ (value) => {
+ if ((value ?? "") !== debouncedKeyword.value) keyword.value = value ?? "";
+ },
+);