Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
15 changes: 15 additions & 0 deletions e2e/downloads.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -847,4 +847,19 @@ test.describe('The render page, without the render service', () => {
await expect(render).not.toHaveAttribute('data-render-error', new RegExp(TOKEN));
await expect(render).not.toHaveAttribute('data-render-ready', 'true');
});

// A result the service holds but cannot answer for is not one a release
// removed: a retry may mend it, so the caller is not told it is gone.
test('says an analysis it could not read is unread, not gone', async ({ page }) => {
await page.route(`**/AnalysisService/token/${TOKEN}**`, (route) =>
route.fulfill({ status: 500, json: { code: 500, reason: 'Internal Server Error' } })
);
await page.goto(`/PathwayBrowser/render/${DIAGRAM}?analysis=${TOKEN}`);
const render = page.locator('cr-render');
await expect(render).toHaveAttribute('data-render-error', /analysis result could not be read/, {
timeout: 120_000,
});
await expect(render).not.toHaveAttribute('data-render-error', new RegExp(TOKEN));
await expect(render).not.toHaveAttribute('data-render-ready', 'true');
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"log":{"version":"1.2","entries":[{"request":{"method":"GET","url":"http://localhost:4330/ContentService/data/database/version"},"response":{"status":200,"headers":[{"name":"Vary","value":"Origin"},{"name":"access-control-allow-origin","value":"*"},{"name":"connection","value":"close"},{"name":"content-length","value":"2"},{"name":"content-type","value":"text/plain;charset=UTF-8"},{"name":"date","value":"Mon, 12 Oct 2026 03:31:22 GMT"}],"content":{"mimeType":"text/plain;charset=UTF-8","_file":"812ed4562d3211363a7b813aa9cd2cf042b63bb2.txt"}}},{"request":{"method":"GET","url":"http://localhost:4330/ContentService/data/species/main"},"response":{"status":200,"headers":[{"name":"Vary","value":"Origin"},{"name":"access-control-allow-origin","value":"*"},{"name":"connection","value":"close"},{"name":"content-length","value":"3237"},{"name":"content-type","value":"application/json; charset=utf-8"},{"name":"date","value":"Mon, 12 Oct 2026 03:31:23 GMT"},{"name":"etag","value":"W/\"ca5-sa7k1xX74ycTbey3jmBLCrhGTO0\""}],"content":{"mimeType":"application/json; charset=utf-8","_file":"b1aee4d715fbe327136decb78e604b0ab8464ced.json"}}},{"request":{"method":"GET","url":"http://localhost:4330/ContentService/data/query/enhanced/v2/R-HSA-109606?fetchIncomingRelationships=true&summariseReferenceEntity=false&includeDisease=true&includeRef=true&view=nested-aggregated"},"response":{"status":200,"headers":[{"name":"Transfer-Encoding","value":"chunked"},{"name":"Vary","value":"Origin"},{"name":"access-control-allow-origin","value":"*"},{"name":"connection","value":"close"},{"name":"content-type","value":"application/json;charset=UTF-8"},{"name":"date","value":"Thu, 17 Sep 2026 19:05:27 GMT"}],"content":{"mimeType":"application/json;charset=UTF-8","_file":"e6c5d5b52f592bbcc221640b529cd2e6cbdabee6.json"}}},{"request":{"method":"GET","url":"http://localhost:4330/ContentService/data/event/R-HSA-109606/ancestors?includeRef=true&view=nested-aggregated"},"response":{"status":200,"headers":[{"name":"Vary","value":"Origin"},{"name":"access-control-allow-origin","value":"*"},{"name":"connection","value":"close"},{"name":"content-length","value":"1117"},{"name":"content-type","value":"application/json;charset=UTF-8"},{"name":"date","value":"Thu, 17 Sep 2026 19:05:28 GMT"}],"content":{"mimeType":"application/json;charset=UTF-8","_file":"7da0e1dc85b67aa8b981fb40bd44e860b499aee5.json"}}},{"request":{"method":"GET","url":"http://localhost:4330/ContentService/data/query/enhanced/v2/R-HSA-109606?fetchIncomingRelationships=true&summariseReferenceEntity=true&includeDisease=true&includeRef=true&view=nested-aggregated"},"response":{"status":200,"headers":[{"name":"Transfer-Encoding","value":"chunked"},{"name":"Vary","value":"Origin"},{"name":"access-control-allow-origin","value":"*"},{"name":"connection","value":"close"},{"name":"content-type","value":"application/json;charset=UTF-8"},{"name":"date","value":"Thu, 17 Sep 2026 19:05:27 GMT"}],"content":{"mimeType":"application/json;charset=UTF-8","_file":"e6c5d5b52f592bbcc221640b529cd2e6cbdabee6.json"}}},{"request":{"method":"GET","url":"http://localhost:4330/ContentService/data/eventsHierarchy/9606?pathwaysOnly=true&token=MADE-UP-RENDER-TOKEN&view=nested-aggregated"},"response":{"status":-1,"headers":[],"content":{"mimeType":"x-unknown"}}},{"request":{"method":"GET","url":"http://localhost:4330/ContentService/data/eventsHierarchy/9606?pathwaysOnly=true&view=nested-aggregated"},"response":{"status":-1,"headers":[],"content":{"mimeType":"x-unknown"}}},{"request":{"method":"GET","url":"http://localhost:4330/ContentService/data/eventsHierarchy/9606?pathwaysOnly=true&view=nested-aggregated"},"response":{"status":200,"headers":[{"name":"Transfer-Encoding","value":"chunked"},{"name":"Vary","value":"Origin"},{"name":"access-control-allow-origin","value":"*"},{"name":"connection","value":"close"},{"name":"content-disposition","value":"inline; swaggerDownload=\"attachment\"; filename=\"9606.json\""},{"name":"content-type","value":"application/json;charset=UTF-8"},{"name":"date","value":"Mon, 12 Oct 2026 03:31:22 GMT"}],"content":{"mimeType":"application/json;charset=UTF-8","_file":"70588a05ec802f3b42d4de1136192d2ff069b195.json"}}}]}}
14 changes: 10 additions & 4 deletions projects/pathway-browser/src/app/diagram/diagram.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -273,7 +273,10 @@ export class DiagramComponent implements AfterViewInit, OnDestroy {
{ debugName: 'diagram selecting' }
);
effect(() => {
const result = this.state.analysis(); // Not in one line to make sure to trigger the update
// Both read here, not left to whatever loadAnalysis happens to read: a new
// token or a new resource each reload the colouring.
const result = this.state.analysis();
this.state.resourceFilter();
this.avoidSideEffect(() => this.loadAnalysis(result));
});
effect(() => {
Expand Down Expand Up @@ -1548,16 +1551,19 @@ export class DiagramComponent implements AfterViewInit, OnDestroy {
return;
}

// What foundEntities and pathwaysResults read when given no resource.
// Read once and given to both loads, so the colouring and appliedAnalysis
// name the same resource.
const resource = this.state.resourceFilter() ?? 'TOTAL';
const load$ = forkJoin({
entities: this.analysis.foundEntities(
this.data.currentPathway()?.normalPathway?.stId || diagramId,
token
token,
resource
),
pathways: this.analysis.pathwaysResults(
this.cy?.nodes('.Pathway').map((p) => p.data('reactomeId')) || [],
token
token,
resource
),
result: this.analysis.result$.pipe(filter(isDefined), take(1)),
}).pipe(
Expand Down
41 changes: 41 additions & 0 deletions projects/pathway-browser/src/app/render/failure-message.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { describe, expect, it } from 'vitest';
import { failureMessage } from './failure-message';

describe('failureMessage', () => {
it('drops the host and keeps the path', () => {
expect(
failureMessage(
'Http failure response for http://localhost:4200/ContentService/data/query/R-HSA-1: 404 Not Found'
)
).toBe('Http failure response for /ContentService/data/query/R-HSA-1: 404 Not Found');
});

it('hides a token in the path', () => {
expect(
failureMessage(
'Http failure response for https://h/AnalysisService/token/MjAyNjExMDIx/found/all/R-HSA-1?resource=TOTAL: 500 Internal Server Error'
)
).toBe(
'Http failure response for /AnalysisService/token/…/found/all/R-HSA-1?resource=TOTAL: 500 Internal Server Error'
);
});

it('hides a token at the end of the path', () => {
expect(failureMessage('for /AnalysisService/token/MjAyNjExMDIx: 410 Gone')).toBe(
'for /AnalysisService/token/…: 410 Gone'
);
});

it('hides a token in the query, as token= or analysis=, wherever it stands', () => {
expect(failureMessage('for /tree?species=9606&token=MjAy&resource=TOTAL: 500')).toBe(
'for /tree?species=9606&token=…&resource=TOTAL: 500'
);
expect(failureMessage('for /render/R-HSA-1?ANALYSIS=MjAy: 500')).toBe(
'for /render/R-HSA-1?ANALYSIS=…: 500'
);
});

it('leaves a message with neither as it was', () => {
expect(failureMessage('no pathway found for R-HSA-1')).toBe('no pathway found for R-HSA-1');
});
});
15 changes: 15 additions & 0 deletions projects/pathway-browser/src/app/render/failure-message.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
/**
* A failed request's message, fit for the render service's answer.
*
* The message names the URL it asked, and the host in it is the address the
* render service reaches the site on, which the service's answer would pass
* on: the path is kept, the host is not. Nor an analysis token, which a failed
* request carries in its query (the overview's tree) or its path (the analysis
* service's own).
*/
export function failureMessage(message: string): string {
return message
.replace(/\bhttps?:\/\/[^/\s]+/g, '')
.replace(/([?&;](?:token|analysis)=)[^&;:\s]+/gi, '$1…')
.replace(/(\/token\/)[^/?;:\s]+/gi, '$1…');
}
18 changes: 6 additions & 12 deletions projects/pathway-browser/src/app/render/render.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ import { DataStateService } from '../services/data-state.service';
import { EventService } from '../services/event.service';
import { ActivatedRoute } from '@angular/router';
import { SvgExporterService } from '../reacfoam/svg-exporter.service';
import { AnalysisService, isGone } from '../services/analysis.service';
import { AnalysisService, isNotFound } from '../services/analysis.service';
import { failureMessage } from './failure-message';
import { EhldService } from '../services/ehld.service';
import { DarkService } from '../services/dark.service';
import { SpeciesService } from '../services/species.service';
Expand Down Expand Up @@ -234,18 +235,9 @@ export class RenderComponent {
// undefined, so a 404 surfaces here rather than through an error signal.
// Without this the wait died silently and the caller paid a full timeout
// to learn nothing.
// A failed request's message names the URL it asked, and the host in
// it is the address the render service reaches the site on, which the
// service's answer would pass on: the path is kept, the host is not.
// Nor an analysis token, which a failed request carries in its query
// (the overview's tree) or its path (the analysis service's own).
const message = failure instanceof Error ? failure.message : String(failure);
this.error.set(
`could not render ${pathway ?? 'the genome-wide view'}: ` +
message
.replace(/\bhttps?:\/\/[^/\s]+/g, '')
.replace(/([?&;](?:token|analysis)=)[^&;:\s]+/gi, '$1…')
.replace(/(\/token\/)[^/?;:\s]+/gi, '$1…')
`could not render ${pathway ?? 'the genome-wide view'}: ${failureMessage(message)}`
);
this.publish();
}
Expand Down Expand Up @@ -337,7 +329,7 @@ export class RenderComponent {
// as one that could not be read just now, so the two are told apart.
const error = this.analysis.resultResource.error();
if (error !== undefined || this.analysis.loadFailure() !== null) {
return (error !== undefined ? isGone(error) : this.analysis.loadFailureGone())
return (error !== undefined ? isNotFound(error) : this.analysis.loadFailureGone())
? 'the analysis result is gone: a release removed it, or it was never on this server'
: 'the analysis result could not be read';
}
Expand All @@ -347,6 +339,8 @@ export class RenderComponent {

/** The analysis the figure is to carry, or null with none. */
private coloured(): { token: string; resource: Analysis.Resource } | null {
// The reaction's own layout is never coloured, so it carries none.
if (this.reactionView) return null;
const token = this.state.analysis();
return token ? { token, resource: this.state.resourceFilter() ?? 'TOTAL' } : null;
}
Expand Down
26 changes: 9 additions & 17 deletions projects/pathway-browser/src/app/services/analysis.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,26 +101,18 @@ export function defaultResource(

/**
* The Analysis Service's "not here": 404 for a pathway the analysis did not
* hit, 404 or 410 for a result it does not hold, which the result's own load
* reports to the reader.
* hit, and 404 or 410 for a result it does not hold. It answers 410 for a
* result it no longer holds -- results are removed at a release -- and for one
* it never held, which is the case for a result another Reactome server
* produced. 404 means the same to a reader.
*/
const isNotFound = (error: unknown) =>
error instanceof HttpErrorResponse && (error.status === 404 || error.status === 410);

/**
* What to tell the reader when an analysis result cannot be loaded.
*
* The Analysis Service answers 410 for a result it no longer holds -- results
* are removed at a release -- and for one it never held, which is the case for
* a result another Reactome server produced. 404 means the same to a reader.
*/
/** Whether a result failed to load because it is not on this server: removed, or never here. */
export function isGone(error: unknown): boolean {
export function isNotFound(error: unknown): boolean {
return error instanceof HttpErrorResponse && (error.status === 404 || error.status === 410);
}

/** What to tell the reader when an analysis result cannot be loaded. */
export function describeLoadFailure(error: unknown): string {
if (isGone(error)) {
if (isNotFound(error)) {
return (
'This analysis result is not available on this server. Results are removed when ' +
'Reactome releases new data, and a result produced elsewhere cannot be opened here. ' +
Expand Down Expand Up @@ -379,7 +371,7 @@ export class AnalysisService {
/** Why the last result the reader asked for could not be shown, if it could not. */
readonly loadFailure = this._loadFailure.asReadonly();
private readonly _loadFailureGone = signal(false);
/** Whether that failure was a result not on this server (see `isGone`). */
/** Whether that failure was a result not on this server (see `isNotFound`). */
readonly loadFailureGone = this._loadFailureGone.asReadonly();

dismissLoadFailure(): void {
Expand Down Expand Up @@ -550,7 +542,7 @@ export class AnalysisService {
if (!error) return;
console.warn('Analysis result could not be loaded', error);
this._loadFailure.set(describeLoadFailure(error));
this._loadFailureGone.set(isGone(error));
this._loadFailureGone.set(isNotFound(error));
this.state.analysis.set(null);
});
// Any new result supersedes the message about the last one.
Expand Down
Loading