-
Notifications
You must be signed in to change notification settings - Fork 1.8k
Expand file tree
/
Copy pathlogs.page.ts
More file actions
288 lines (260 loc) · 10.9 KB
/
Copy pathlogs.page.ts
File metadata and controls
288 lines (260 loc) · 10.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
import { test, expect, type Page, type Locator } from '@playwright/test';
import { loadEnvConfig } from '../config/env.config';
import { TracePanelPage } from './trace-panel.page';
import { ThreadPanelPage } from './thread-panel.page';
export type ExplainKind = 'error' | 'duration' | 'cost';
// Maps an explain kind to the Traces table column id (used in data-cell-id)
// and the owl trigger's aria-label, per apps/opik-frontend/src/plugins/comet/explain/registry.ts.
const EXPLAIN_COLUMN: Record<ExplainKind, string> = {
error: 'error_info',
duration: 'duration',
cost: 'total_estimated_cost',
};
const EXPLAIN_LABEL: Record<ExplainKind, string> = {
error: 'Explain error',
duration: 'Explain duration',
cost: 'Explain cost',
};
export class LogsPage {
private projectId: string | null = null;
constructor(private readonly page: Page) {}
async goto(projectId: string): Promise<void> {
return test.step(`Open Logs for project ${projectId}`, async () => {
this.projectId = projectId;
const env = loadEnvConfig();
await this.page.goto(`${env.baseUrl}/${env.workspace}/projects/${projectId}/logs`);
});
}
/** Open Logs with the Threads tab active for the given project. */
async gotoThreads(projectId: string): Promise<void> {
return test.step(`Open Logs (Threads) for project ${projectId}`, async () => {
this.projectId = projectId;
const env = loadEnvConfig();
await this.page.goto(
`${env.baseUrl}/${env.workspace}/projects/${projectId}/logs?logsType=threads`,
);
});
}
async waitForReady(): Promise<void> {
return test.step('Wait for Logs table ready', async () => {
const realRow = this.page.locator('tr[data-row-id]').first();
const emptyState = this.page.getByText('No traces yet');
await Promise.race([
realRow.waitFor({ state: 'visible' }),
emptyState.waitFor({ state: 'visible' }),
]);
await this.page.waitForFunction(() => {
const txt = document.body.innerText;
return /Traces\s+\d+/i.test(txt);
});
});
}
async countTraces(): Promise<number> {
return test.step('Read trace count', async () => {
// Prefer the value-only testid so we never accidentally parse the delta
// (e.g. "+5.0%") that the card also renders.
const valueEl = this.page.getByTestId('metrics-card-count-value');
if (await valueEl.isVisible().catch(() => false)) {
const text = (await valueEl.textContent()) ?? '';
const digits = text.replace(/\D/g, '');
if (digits) return Number(digits);
}
// Fallback for staging deploys that don't yet have the value-only testid:
// pull the count out of the "Traces N" stat text in the body.
const handle = await this.page.waitForFunction(() => {
const txt = document.body.innerText;
const m = txt.match(/Traces\s+(\d+)/i);
return m ? Number(m[1]) : null;
});
return (await handle.jsonValue()) as number;
});
}
async openTraceById(traceId: string): Promise<TracePanelPage> {
return test.step(`Open trace ${traceId}`, async () => {
if (!this.projectId) {
throw new Error('LogsPage.openTraceById: call goto(projectId) first');
}
const env = loadEnvConfig();
const url = `${env.baseUrl}/${env.workspace}/projects/${this.projectId}/logs?trace=${traceId}`;
await this.page.goto(url);
return new TracePanelPage(this.page, traceId);
});
}
async openFirstTrace(): Promise<TracePanelPage> {
return test.step('Open first trace in table', async () => {
const row = this.traceRows.first();
await row.waitFor({ state: 'visible' });
const traceId = await row.getAttribute('data-row-id');
if (!traceId) {
throw new Error('LogsPage.openFirstTrace: first row has no data-row-id attribute');
}
await row.click();
return new TracePanelPage(this.page, traceId);
});
}
async readTraceIdsInOrder(): Promise<string[]> {
return test.step('Read trace IDs in table order', async () => {
await this.traceRows.first().waitFor({ state: 'visible' });
const rows = await this.traceRows.all();
const ids: string[] = [];
for (const row of rows) {
const id = await row.getAttribute('data-row-id');
if (id) ids.push(id);
}
return ids;
});
}
/**
* The current project's item in the breadcrumb, shown when navigated to /logs.
* Matched by text rather than role: older UIs render it as a link, newer ones
* (project menu redesign) as a dropdown button — the name is present in both.
*/
breadcrumbProjectLink(projectName: string): Locator {
return this.page
.getByRole('navigation', { name: 'breadcrumb' })
.getByText(projectName, { exact: true });
}
get traceRows(): Locator {
return this.page.locator('tr[data-row-id]');
}
/** The Errors/Duration/Estimated cost cell for a trace row, keyed by Ollie explain kind. */
explainCell(traceId: string, kind: ExplainKind): Locator {
return this.page.locator(`[data-cell-id="${traceId}_${EXPLAIN_COLUMN[kind]}"]`);
}
/**
* Hover a trace's Errors/Duration/Estimated cost cell and click its Ollie
* "Explain" owl trigger, opening the popover. The trigger only renders once
* the Ollie assistant bridge handshake (mounted via the page's assistant
* sidebar) completes, which can lag a beat after the table itself is
* interactive — so this polls hover+lookup rather than asserting once.
*/
async openExplain(traceId: string, kind: ExplainKind, timeoutMs = 60_000): Promise<void> {
return test.step(`open Ollie explain (${kind}) for trace ${traceId}`, async () => {
const cell = this.explainCell(traceId, kind);
const button = cell.getByRole('button', { name: EXPLAIN_LABEL[kind] });
await expect
.poll(
async () => {
await cell.hover();
return button.count();
},
{ timeout: timeoutMs, intervals: [500, 1000, 2000] },
)
.toBeGreaterThan(0);
await button.click();
});
}
/**
* Wait for the open Ollie explain popover to settle (loading -> done/error)
* and return its rendered text. Scoped to the last `[role="status"]` live
* region on the page — Radix unmounts a closed popover's content, so only
* the currently-open one's region should be present.
*/
async readExplanation(timeoutMs = 60_000): Promise<string> {
return test.step('wait for Ollie explain popover to settle', async () => {
const status = this.page.locator('[role="status"]').last();
await expect(status).toHaveAttribute('aria-busy', 'false', { timeout: timeoutMs });
const text = ((await status.textContent()) ?? '').trim();
if (!text) {
throw new Error('Ollie explain popover settled but rendered no text');
}
return text;
});
}
/** Close the open Ollie explain popover. */
async closeExplain(): Promise<void> {
return test.step('close Ollie explain popover', async () => {
await this.page.keyboard.press('Escape');
});
}
/**
* The "Continue conversation" link in the currently open Ollie explain
* popover. Only rendered once the popover has settled with text (see
* ExplainPopover.tsx) — call after `readExplanation()`.
*/
continueConversationButton(): Locator {
return this.page.getByRole('button', { name: 'Continue conversation' });
}
/**
* Click "Continue conversation" to hand the explain popover's question +
* cached answer off to the Ollie sidebar chat. This closes the popover as
* a side effect (see ExplainPopover's onContinue).
*/
async continueConversation(): Promise<void> {
return test.step('continue the Ollie explain conversation in the sidebar', async () => {
await this.continueConversationButton().click();
});
}
// --- Threads tab ---
/** The Threads/Traces/Spans tab toggle for "Threads". */
get threadsTab(): Locator {
return this.page.getByRole('radio', { name: 'Threads' });
}
/**
* Wait for the Threads table to be ready. When a threadId is given, wait for
* that specific row — threads are eventually consistent, so gating on "any
* row" can pass before the seeded thread has been aggregated into the list.
*/
async waitForThreadsReady(threadId?: string): Promise<void> {
return test.step('Wait for Threads table ready', async () => {
const target = threadId
? this.threadRow(threadId)
: this.page.locator('tr[data-row-id]').first();
await target.waitFor({ state: 'visible' });
});
}
/**
* The number shown in the "Threads" metrics card. The Threads view reuses the
* same count-card testid as the Traces view; with the tab active this is the
* thread count.
*/
async countThreads(): Promise<number> {
return test.step('Read thread count', async () => {
const valueEl = this.page.getByTestId('metrics-card-count-value');
await valueEl.waitFor({ state: 'visible' });
const text = (await valueEl.textContent()) ?? '';
const digits = text.replace(/\D/g, '');
return digits ? Number(digits) : 0;
});
}
/** A thread row, keyed by thread id (the row's data-row-id IS the thread id). */
threadRow(threadId: string): Locator {
return this.page.locator(`tr[data-row-id="${threadId}"]`);
}
/**
* Read the "Message count" cell for a thread. Note: the Threads view counts
* messages, so a conversation of N turns (N traces) reports 2*N messages
* (each trace contributes an input and an output message).
*/
async readThreadMessageCount(threadId: string): Promise<number> {
return test.step(`Read message count for thread ${threadId}`, async () => {
const cell = this.threadRow(threadId).locator(
`[data-cell-id="${threadId}_number_of_messages"]`,
);
await cell.waitFor({ state: 'visible' });
const text = (await cell.textContent()) ?? '';
const digits = text.replace(/\D/g, '');
return digits ? Number(digits) : 0;
});
}
/** The "First message" cell text for a thread. */
threadFirstMessageCell(threadId: string): Locator {
return this.threadRow(threadId).locator(`[data-cell-id="${threadId}_first_message"]`);
}
/** The "Last message" cell text for a thread. */
threadLastMessageCell(threadId: string): Locator {
return this.threadRow(threadId).locator(`[data-cell-id="${threadId}_last_message"]`);
}
/** Open a thread's detail panel by id, returning the conversation panel POM. */
async openThreadById(threadId: string): Promise<ThreadPanelPage> {
return test.step(`Open thread ${threadId}`, async () => {
if (!this.projectId) {
throw new Error('LogsPage.openThreadById: call gotoThreads(projectId) first');
}
const env = loadEnvConfig();
const url = `${env.baseUrl}/${env.workspace}/projects/${this.projectId}/logs?logsType=threads&thread=${threadId}`;
await this.page.goto(url);
return new ThreadPanelPage(this.page, threadId);
});
}
}