Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
b3d515a
add WIP version of collab puppeteer test
philippotto May 11, 2026
e48ddc5
add vitest config for collab test
philippotto May 11, 2026
fdcd598
fix getUsers
philippotto May 11, 2026
fc72c18
only use token for authentication. also reuse existing browser setup
philippotto May 11, 2026
dab5a28
fix permissions for dataset
philippotto May 11, 2026
d0caccb
use proper values for supervoxels; improve typing
philippotto May 11, 2026
d2e77a4
Merge branch 'master' of github.com:scalableminds/webknossos into pup…
philippotto May 11, 2026
c0bf2d0
DRY waitForTracingViewLoad
philippotto May 12, 2026
719125c
fix dataset team permission and make admin also a super user to fix c…
philippotto May 12, 2026
ca54115
multiple fixes
philippotto May 12, 2026
18c75b8
Make mutex acquisition exclusive even when requests reach server near…
MichaelBuessemeyer May 13, 2026
d104358
fix collab mode setting
philippotto May 13, 2026
268b2f0
format
philippotto May 13, 2026
29e7f37
Merge branch 'puppeteer-live-testing' of github.com:scalableminds/web…
philippotto May 13, 2026
cf3cf97
fix typescript
philippotto May 13, 2026
52d3a00
Fix db request upserting annotation mutex or returning existing valid…
MichaelBuessemeyer May 13, 2026
8f4e8b4
fix request syntax
MichaelBuessemeyer May 13, 2026
bf15a0a
misc changes in save mutex saga
philippotto May 13, 2026
f653194
Merge branch 'puppeteer-live-testing' of github.com:scalableminds/web…
philippotto May 13, 2026
3656a59
deduplicate upsert statement
MichaelBuessemeyer May 13, 2026
953f141
Merge branch 'puppeteer-live-testing' of github.com:scalableminds/web…
MichaelBuessemeyer May 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 32 additions & 42 deletions app/models/annotation/AnnotationMutexService.scala
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import com.scalableminds.webknossos.datastore.helpers.IntervalScheduler
import com.scalableminds.webknossos.schema.Tables.AnnotationMutexesRow
import com.typesafe.scalalogging.LazyLogging
import models.user.{UserDAO, UserService}
import com.scalableminds.util.tools.Full
import play.api.inject.ApplicationLifecycle
import play.api.libs.json.{JsObject, Json}
import utils.WkConf
Expand Down Expand Up @@ -43,30 +42,16 @@ class AnnotationMutexService @Inject()(val lifecycle: ApplicationLifecycle,
private val defaultExpiryTime = wkConf.WebKnossos.Annotation.Mutex.expiryTime

def tryAcquiringAnnotationMutex(annotationId: ObjectId, userId: ObjectId): Fox[MutexResult] =
this.synchronized {
for {
mutexBox <- annotationMutexDAO.findOne(annotationId).shiftBox
result <- mutexBox match {
case Full(mutex) =>
if (mutex.userId == userId)
refresh(mutex)
else
Fox.successful(MutexResult(canEdit = false, blockedByUser = Some(mutex.userId)))
case _ =>
acquire(annotationId, userId)
}
} yield result
}

private def acquire(annotationId: ObjectId, userId: ObjectId): Fox[MutexResult] =
for {
_ <- annotationMutexDAO.upsertOne(AnnotationMutex(annotationId, userId, Instant.in(defaultExpiryTime)))
} yield MutexResult(canEdit = true, None)

private def refresh(mutex: AnnotationMutex): Fox[MutexResult] =
for {
_ <- annotationMutexDAO.upsertOne(mutex.copy(expiry = Instant.in(defaultExpiryTime)))
} yield MutexResult(canEdit = true, None)
_ <- Fox.successful(logger.info(s"Try acquire mutex inner for user $userId and id $annotationId."))
ownerUserId <- annotationMutexDAO.tryAcquireReturningOwner(annotationId, userId, Instant.in(defaultExpiryTime)) ?~> "annotationMutexDAO.tryAcquireReturningOwner failed"
_ <- Fox.successful(
logger.info(s"Try acquire mutex inner for user $userId and id $annotationId got ownerUserId $ownerUserId."))
result = if (ownerUserId == userId)
MutexResult(canEdit = true, None)
else
MutexResult(canEdit = false, blockedByUser = Some(ownerUserId))
} yield result

def release(annotationId: ObjectId, userId: ObjectId): Fox[Unit] =
annotationMutexDAO.deleteForUser(annotationId, userId)
Expand All @@ -93,26 +78,31 @@ class AnnotationMutexDAO @Inject()(sqlClient: SqlClient)(implicit ec: ExecutionC
Instant.fromSql(r.expiry)
)

def findOne(annotationId: ObjectId): Fox[AnnotationMutex] =
def tryAcquireReturningOwner(annotationId: ObjectId, userId: ObjectId, expiry: Instant): Fox[ObjectId] =
for {
rows <- run(q"""SELECT _annotation, _user, expiry
FROM webknossos.annotation_mutexes
WHERE _annotation = $annotationId
AND expiry > NOW()""".as[AnnotationMutexesRow])
first <- rows.headOption.toFox
rows <- run(q"""WITH attempt AS (
INSERT INTO webknossos.annotation_mutexes(_annotation, _user, expiry)
VALUES($annotationId, $userId, $expiry)
ON CONFLICT (_annotation)
DO UPDATE SET
_user = EXCLUDED._user,
expiry = EXCLUDED.expiry
WHERE webknossos.annotation_mutexes._user = EXCLUDED._user
OR webknossos.annotation_mutexes.expiry < NOW()
RETURNING _annotation, _user, expiry
)
SELECT _annotation, _user, expiry FROM attempt

UNION ALL

SELECT _annotation, _user, expiry
FROM webknossos.annotation_mutexes
WHERE _annotation = $annotationId
AND expiry >= NOW()
AND NOT EXISTS (SELECT 1 FROM attempt)""".as[AnnotationMutexesRow]) ~> "Upserting annotation mutex failed."
first <- rows.headOption.toFox ~> "Could not find mutex for annotation."
parsed = parse(first)
} yield parsed

def upsertOne(annotationMutex: AnnotationMutex): Fox[Unit] =
for {
_ <- run(q"""INSERT INTO webknossos.annotation_mutexes(_annotation, _user, expiry)
VALUES(${annotationMutex.annotationId}, ${annotationMutex.userId}, ${annotationMutex.expiry})
ON CONFLICT (_annotation)
DO UPDATE SET
_user = ${annotationMutex.userId},
expiry = ${annotationMutex.expiry}
""".asUpdate)
} yield ()
} yield parsed.userId

def deleteExpired(): Fox[Int] =
run(q"DELETE FROM webknossos.annotation_mutexes WHERE expiry < NOW()".asUpdate)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ export function OrganizationOverviewView() {
error: usersError,
} = useQuery({
queryKey: ["users"],
queryFn: getUsers,
queryFn: () => getUsers(),
});

const {
Expand Down
23 changes: 16 additions & 7 deletions frontend/javascripts/admin/rest_api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,8 +169,8 @@ export async function logoutUserEverywhere(): Promise<void> {
await Request.receiveJSON("/api/auth/logoutEverywhere", { method: "POST" });
}

export async function getUsers(): Promise<Array<APIUser>> {
const users = await Request.receiveJSON("/api/users");
export async function getUsers(options: RequestOptions = {}): Promise<Array<APIUser>> {
const users = await Request.receiveJSON("/api/users", options);
assertResponseLimit(users);
return users;
}
Expand Down Expand Up @@ -328,10 +328,13 @@ export function updateTaskType(taskTypeId: string, taskType: APITaskType): Promi
}

// ### Teams
export async function getTeams(): Promise<Array<APITeam>> {
const teams = await Request.receiveJSON("/api/teams", {
doNotInvestigate: true,
});
export async function getTeams(options?: RequestOptions): Promise<Array<APITeam>> {
const teams = await Request.receiveJSON(
"/api/teams",
options ?? {
doNotInvestigate: true,
},
);
assertResponseLimit(teams);
return teams;
}
Expand Down Expand Up @@ -548,11 +551,13 @@ export function setCollaborationModeForAnnotation(
annotationId: string,
annotationType: APIAnnotationType,
collaborationMode: AnnotationCollaborationMode,
options?: RequestOptions,
): Promise<void> {
return Request.receiveJSON(
`/api/annotations/${annotationType}/${annotationId}/collaborationMode?collaborationMode=${collaborationMode}`,
{
method: "PATCH",
...options,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Prevent RequestOptions from overriding fixed PATCH payload/method.

...options is currently applied after method/data, so callers can unintentionally override critical request semantics (Line 560, Line 1174, Line 1479). Apply ...options first, then set immutable fields.

Suggested fix
 return Request.receiveJSON(
   `/api/annotations/${annotationType}/${annotationId}/collaborationMode?collaborationMode=${collaborationMode}`,
   {
-    method: "PATCH",
     ...options,
+    method: "PATCH",
   },
 );
 return Request.sendJSONReceiveJSON(`/api/datasets/${datasetId}/updatePartial`, {
-  method: "PATCH",
-  data: updater,
   ...options,
+  method: "PATCH",
+  data: updater,
 });
 return Request.sendJSONReceiveJSON(`/api/datasets/${datasetId}/teams`, {
-  method: "PATCH",
-  data: newTeams,
   ...options,
+  method: "PATCH",
+  data: newTeams,
 });

Also applies to: 1174-1174, 1479-1479

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/javascripts/admin/rest_api.ts` at line 560, Callers can override the
fixed PATCH payload/method because the spread `...options` is currently placed
after `method`/`data`; move `...options` earlier so immutable fields are set
last. Locate occurrences where a request object is built with `...options` and
`method`/`data` (the spots around the `...options,` spread and the
`RequestOptions` construction), place `...options` before the explicit `method:
'PATCH'` and `data: ...` assignments (or any other method/data fields) so those
fields are always immutable regardless of caller-supplied options.

},
);
}
Expand Down Expand Up @@ -1160,11 +1165,13 @@ export type DatasetUpdater = {

export function updateDatasetPartial(
datasetId: string,
updater: DatasetUpdater,
updater: Partial<DatasetUpdater>,
options: RequestOptions = {},
): Promise<APIDataset> {
return Request.sendJSONReceiveJSON(`/api/datasets/${datasetId}/updatePartial`, {
method: "PATCH",
data: updater,
...options,
});
}

Expand Down Expand Up @@ -1464,10 +1471,12 @@ export async function isDatasetNameValid(datasetName: string): Promise<string |
export function updateDatasetTeams(
datasetId: string,
newTeams: Array<string>,
options: RequestOptions = {},
): Promise<APIDataset> {
return Request.sendJSONReceiveJSON(`/api/datasets/${datasetId}/teams`, {
method: "PATCH",
data: newTeams,
...options,
});
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ function TimeTrackingOverview() {

const { data: allTeams = [], isLoading: isTeamsLoading } = useQueryWithErrorHandling({
queryKey: ["teams"],
queryFn: getTeams,
queryFn: () => getTeams(),
});

const [selectedProjectIds, setSelectedProjectIds] = useState<string[]>([]);
Expand Down
13 changes: 9 additions & 4 deletions frontend/javascripts/libs/handle_request_error_helper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,11 +77,16 @@ export const handleError = async (
}
}

// If doInvestigate is false or the error is not instanceof Response,
// still add additional information to the error
if (!(error instanceof Response)) {
error.message += ` - Url: ${requestedUrl}`;
// If doInvestigate is false but the error is still a Response, read the body
// so callers get a proper Error with the status and body text instead of a
// raw Response object (whose body is a ReadableStream that can't be inspected).
if (error instanceof Response) {
const text = await error.text().catch(() => "<unreadable body>");
return Promise.reject(
new Error(`Request failed with status ${error.status} for ${requestedUrl}: ${text}`),
);
}

error.message += ` - Url: ${requestedUrl}`;
return Promise.reject(error);
};
21 changes: 13 additions & 8 deletions frontend/javascripts/test/puppeteer/dataset_rendering_helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,7 @@ async function waitForMappingEnabled(page: Page) {
console.log("Mapping was enabled");
}

async function waitForTracingViewLoad(page: Page) {
export async function waitForTracingViewLoad(page: Page) {
let inputCatchers;
let iterationCount = 0;

Expand Down Expand Up @@ -404,10 +404,11 @@ export interface ScreenshotTestContext extends TestContext {
browser: Browser;
}

export async function setupBeforeEach(context: ScreenshotTestContext) {
export async function launchBrowser(sessionName?: string): Promise<Browser> {
let browser: Browser;

if (USE_LOCAL_CHROME) {
// Use this for connecting to local Chrome browser instance
context.browser = await puppeteer.launch({
browser = await puppeteer.launch({
args: (HEADLESS
? [
"--hide-scrollbars",
Expand All @@ -431,20 +432,24 @@ export async function setupBeforeEach(context: ScreenshotTestContext) {
browser_version: "latest",
os: "os x",
os_version: "sequoia",
name: context.task.name, // add test name to BrowserStack session
name: sessionName,
"browserstack.username": process.env.BROWSERSTACK_USERNAME,
"browserstack.accessKey": process.env.BROWSERSTACK_ACCESS_KEY,
};
const browser = await puppeteer.connect({
browser = await puppeteer.connect({
browserWSEndpoint: `ws://cdp.browserstack.com/puppeteer?caps=${encodeURIComponent(
JSON.stringify(caps),
)}`,
});
context.browser = browser;
console.log(`\nBrowserStack Session Id ${await getBrowserstackSessionId(browser)}\n`);
}

console.log(`\nRunning chrome version ${await context.browser.version()}\n`);
console.log(`\nRunning chrome version ${await browser.version()}\n`);
return browser;
}

export async function setupBeforeEach(context: ScreenshotTestContext) {
context.browser = await launchBrowser(context.task.name);
}

export async function setupAfterEach(context: ScreenshotTestContext) {
Expand Down
Loading
Loading