@crvouga/atlas 0.14.0 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +6 -0
- package/dist/reports/src/artifact-tag-store.d.ts +20 -0
- package/dist/reports/src/artifact-tag-store.js +70 -0
- package/dist/reports/src/artifact-tags.d.ts +96 -0
- package/dist/reports/src/artifact-tags.js +96 -0
- package/dist/reports/src/evidence-detail.d.ts +1 -1
- package/dist/reports/src/evidence-detail.js +6 -2
- package/dist/reports/src/evidence-types.d.ts +2 -0
- package/dist/reports/src/reports-explorer-route.d.ts +8 -0
- package/dist/reports/src/reports-route.d.ts +8 -0
- package/dist/reports/src/test-catalog.d.ts +11 -23
- package/dist/reports/src/test-catalog.js +42 -4
- package/dist/schema/src/api.d.ts +79 -0
- package/dist/schema/src/api.js +31 -1
- package/dist/schema/src/layer.d.ts +33 -0
- package/dist/schema/src/layer.js +11 -1
- package/dist/server/src/app.js +32 -1
- package/dist/server/src/openapi.d.ts +1 -1
- package/dist/server/src/openapi.js +18 -2
- package/docs/extending.md +3 -0
- package/docs/reference/http-api.md +48 -0
- package/docs/reference/reports.md +217 -2
- package/docs/reference/schema.md +32 -0
- package/docs/rich-evidence.md +28 -0
- package/docs/server.md +24 -0
- package/llms-full.txt +103 -0
- package/package.json +1 -1
- package/packages/schema/schemas/check-detail.schema.json +28 -0
- package/packages/schema/schemas/effective-delta.schema.json +14 -0
- package/packages/schema/schemas/effective-evidence.schema.json +14 -0
- package/packages/schema/schemas/evidence-page.schema.json +7 -0
- package/packages/schema/schemas/layer-rows.schema.json +7 -0
- package/packages/schema/schemas/layer.schema.json +70 -0
- package/packages/schema/schemas/publish-checks.schema.json +35 -0
- package/packages/schema/schemas/test-catalog.schema.json +42 -0
- package/packages/schema/schemas/test-history.schema.json +28 -0
- package/packages/server/openapi.json +732 -1
- package/packages/visualizer/src/evidence/artifact-tags.tsx +285 -0
- package/packages/visualizer/src/evidence/artifacts.tsx +18 -12
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# Changelog: @crvouga/atlas
|
|
2
2
|
|
|
3
|
+
## 0.15.0 (2026-10-28)
|
|
4
|
+
|
|
5
|
+
### Features
|
|
6
|
+
|
|
7
|
+
- tag artifacts and demos with visible, searchable metadata ([833114a](https://github.com/crvouga/atlas/commit/833114ae543fa9a1bfe796b5991611efa02bd098))
|
|
8
|
+
|
|
3
9
|
## 0.14.0 (2026-10-28)
|
|
4
10
|
|
|
5
11
|
### Features
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { ObjectStore } from './object-store.js';
|
|
2
|
+
import { type ArtifactTagEntry } from './artifact-tags.js';
|
|
3
|
+
/** The prefix of every recorded edit in a store. */
|
|
4
|
+
export declare const ARTIFACT_TAGS_PREFIX = "curation/artifact-tags/";
|
|
5
|
+
/** The edit was not acceptable (`status` is the HTTP status to answer). */
|
|
6
|
+
export declare class ArtifactTagError extends Error {
|
|
7
|
+
readonly status: 422;
|
|
8
|
+
constructor(status: 422, message: string);
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Validates an edit (`ArtifactTagEditSchema`), normalizes its tags and records it as a new object in `store`, by
|
|
12
|
+
* `by` at `atMs`. Throws {@link ArtifactTagError} for a body that is not an edit, a tag that is not a tag (empty, or
|
|
13
|
+
* over 64 characters) or an edit that changes nothing. Recording the same edit twice records two entries.
|
|
14
|
+
*/
|
|
15
|
+
export declare function recordArtifactTags(store: ObjectStore, input: unknown, by: string, atMs: number): Promise<ArtifactTagEntry>;
|
|
16
|
+
/**
|
|
17
|
+
* Every recorded edit in `store`, oldest first. An object that cannot be read as an edit is skipped (unknown, never
|
|
18
|
+
* guessed at). A store with none gives an empty list. Transport errors throw.
|
|
19
|
+
*/
|
|
20
|
+
export declare function readArtifactTagEntries(store: ObjectStore): Promise<ArtifactTagEntry[]>;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module Reviewer tags in a store
|
|
3
|
+
*
|
|
4
|
+
* Where a reviewer's tags for attachments are kept: one small immutable object per edit under
|
|
5
|
+
* `curation/artifact-tags/`, named by time and a random id so editors never overwrite one another, and never changed
|
|
6
|
+
* or deleted afterwards. The layers they are about are not touched. The list of edits is the audit trail: who changed
|
|
7
|
+
* which tags of which attachment, and when. `foldArtifactTags` turns it into the tags that stand now.
|
|
8
|
+
*
|
|
9
|
+
* @see docs/server.md#tagging-artifacts
|
|
10
|
+
*/
|
|
11
|
+
import { ArtifactTagEntrySchema, ArtifactTagEditSchema } from '../../schema/src/api.js';
|
|
12
|
+
import { readUtf8, writeUtf8 } from './object-io.js';
|
|
13
|
+
import { normalizeTag, normalizeTags } from './artifact-tags.js';
|
|
14
|
+
/** The prefix of every recorded edit in a store. */
|
|
15
|
+
export const ARTIFACT_TAGS_PREFIX = 'curation/artifact-tags/';
|
|
16
|
+
/** The edit was not acceptable (`status` is the HTTP status to answer). */
|
|
17
|
+
export class ArtifactTagError extends Error {
|
|
18
|
+
status;
|
|
19
|
+
constructor(status, message) {
|
|
20
|
+
super(message);
|
|
21
|
+
this.status = status;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Validates an edit (`ArtifactTagEditSchema`), normalizes its tags and records it as a new object in `store`, by
|
|
26
|
+
* `by` at `atMs`. Throws {@link ArtifactTagError} for a body that is not an edit, a tag that is not a tag (empty, or
|
|
27
|
+
* over 64 characters) or an edit that changes nothing. Recording the same edit twice records two entries.
|
|
28
|
+
*/
|
|
29
|
+
export async function recordArtifactTags(store, input, by, atMs) {
|
|
30
|
+
const parsed = ArtifactTagEditSchema.safeParse(input);
|
|
31
|
+
if (!parsed.success)
|
|
32
|
+
throw new ArtifactTagError(422, `Not a tag edit: ${parsed.error.issues[0]?.path.join('.') || '(body)'}: ${parsed.error.issues[0]?.message ?? 'invalid'}`);
|
|
33
|
+
const { target, add = [], remove = [] } = parsed.data;
|
|
34
|
+
if ([...add, ...remove].some(tag => normalizeTag(tag) === null))
|
|
35
|
+
throw new ArtifactTagError(422, 'A tag is empty or longer than 64 characters.');
|
|
36
|
+
const added = normalizeTags(add);
|
|
37
|
+
const removed = normalizeTags(remove);
|
|
38
|
+
const entryAdd = added.filter(tag => !removed.includes(tag));
|
|
39
|
+
const entryRemove = removed.filter(tag => !added.includes(tag));
|
|
40
|
+
if (!entryAdd.length && !entryRemove.length)
|
|
41
|
+
throw new ArtifactTagError(422, 'Name at least one tag to add or remove.');
|
|
42
|
+
const id = `${String(atMs).padStart(16, '0')}-${globalThis.crypto.randomUUID().replace(/-/g, '').slice(0, 12)}`;
|
|
43
|
+
const entry = {
|
|
44
|
+
id, by, atMs,
|
|
45
|
+
target: { layer: target.layer, check: target.check, ...(target.kind === undefined ? {} : { kind: target.kind }), artifact: target.artifact },
|
|
46
|
+
add: entryAdd, remove: entryRemove
|
|
47
|
+
};
|
|
48
|
+
await writeUtf8(store, `${ARTIFACT_TAGS_PREFIX}${id}.json`, JSON.stringify({ schemaVersion: 1, ...entry }), 'application/json');
|
|
49
|
+
return entry;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Every recorded edit in `store`, oldest first. An object that cannot be read as an edit is skipped (unknown, never
|
|
53
|
+
* guessed at). A store with none gives an empty list. Transport errors throw.
|
|
54
|
+
*/
|
|
55
|
+
export async function readArtifactTagEntries(store) {
|
|
56
|
+
const keys = (await store.listKeys(ARTIFACT_TAGS_PREFIX)).filter(key => key.endsWith('.json'));
|
|
57
|
+
const read = await Promise.all(keys.map(async (key) => {
|
|
58
|
+
const text = await readUtf8(store, key);
|
|
59
|
+
if (text === null)
|
|
60
|
+
return [];
|
|
61
|
+
try {
|
|
62
|
+
const parsed = ArtifactTagEntrySchema.safeParse(JSON.parse(text));
|
|
63
|
+
return parsed.success ? [parsed.data] : [];
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return [];
|
|
67
|
+
}
|
|
68
|
+
}));
|
|
69
|
+
return read.flat().sort((a, b) => a.atMs - b.atMs || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
70
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module Artifact tags
|
|
3
|
+
*
|
|
4
|
+
* The tags of one attachment (a recording, a screenshot, a trace): how a tag is normalized so `demo` and `#demo`
|
|
5
|
+
* are the same tag, which tags an attachment shows (its producer's, its reviewers', and those it inherits from its
|
|
6
|
+
* check and run), and how a reviewer's edits fold into the tags that stand now. Pure functions, safe in a browser.
|
|
7
|
+
*
|
|
8
|
+
* Tags describe evidence; they never decide a status, a slot or a winner. A producer writes `tags` on the attachment
|
|
9
|
+
* inside an immutable layer. A reviewer's tags live apart, as an append-only list of edits that the server records
|
|
10
|
+
* with who made each and when (`artifact-tag-store.ts`); the layer is never rewritten.
|
|
11
|
+
*
|
|
12
|
+
* @see docs/rich-evidence.md#tagging-artifacts-and-demos
|
|
13
|
+
*/
|
|
14
|
+
import type { Attachment } from '../../schema/src/layer.js';
|
|
15
|
+
/** The longest a normalized tag may be, in characters. */
|
|
16
|
+
export declare const MAX_TAG_LENGTH = 64;
|
|
17
|
+
/**
|
|
18
|
+
* Normalizes a tag so equal tags compare equal: Unicode NFC, any leading `#` removed, surrounding whitespace
|
|
19
|
+
* trimmed, letters lower-cased and each run of whitespace inside turned into one `-`. Every other character is kept,
|
|
20
|
+
* so `Smoke`, `#smoke` and ` smoke ` are `smoke`, and `team/payments` or `release=2` stay as they are. Returns
|
|
21
|
+
* `null` for a tag that is empty after that (`''`, `'#'`, `' '`) or longer than {@link MAX_TAG_LENGTH}.
|
|
22
|
+
*
|
|
23
|
+
* @example
|
|
24
|
+
* ```ts
|
|
25
|
+
* normalizeTag('#Demo'); // 'demo'
|
|
26
|
+
* normalizeTag('release candidate'); // 'release-candidate'
|
|
27
|
+
* normalizeTag('#'); // null
|
|
28
|
+
* ```
|
|
29
|
+
*/
|
|
30
|
+
export declare function normalizeTag(raw: string): string | null;
|
|
31
|
+
/** Normalizes every tag with {@link normalizeTag}, dropping the ones that are not tags and the repeats; the first of each keeps its place. */
|
|
32
|
+
export declare function normalizeTags(raw: Iterable<string> | undefined): string[];
|
|
33
|
+
/** The tag as a person sees it: `#demo`. */
|
|
34
|
+
export declare const displayTag: (tag: string) => string;
|
|
35
|
+
/**
|
|
36
|
+
* How an edit names an attachment: `id:<id>` when it has an `id`, else `key:<bundleKey>` (its bytes), else
|
|
37
|
+
* `name:<name>`. It is stable for a layer, which never changes. Two attachments in one check with the same bytes and
|
|
38
|
+
* no `id` share a reference, and so share reviewer tags; give them an `id` to tag them apart.
|
|
39
|
+
*/
|
|
40
|
+
export declare function artifactRef(attachment: Pick<Attachment, 'id' | 'bundleKey' | 'name'>): string;
|
|
41
|
+
/** Where a reviewer's tags are about: one attachment of one check of one archived layer. */
|
|
42
|
+
export type ArtifactTarget = {
|
|
43
|
+
readonly layer: string;
|
|
44
|
+
readonly check: string;
|
|
45
|
+
readonly kind?: string | undefined;
|
|
46
|
+
readonly artifact: string;
|
|
47
|
+
};
|
|
48
|
+
/** One recorded edit. Entries are never changed or removed. */
|
|
49
|
+
export type ArtifactTagEntry = {
|
|
50
|
+
readonly id: string;
|
|
51
|
+
readonly target: ArtifactTarget;
|
|
52
|
+
readonly add: readonly string[];
|
|
53
|
+
readonly remove: readonly string[];
|
|
54
|
+
readonly by: string;
|
|
55
|
+
readonly atMs: number;
|
|
56
|
+
};
|
|
57
|
+
/** A string that is equal for equal targets (the part `kind` is optional and then does not matter). */
|
|
58
|
+
export declare const targetKey: (target: ArtifactTarget) => string;
|
|
59
|
+
/** A tag a reviewer added: who added it last, and when. */
|
|
60
|
+
export type ReviewerTag = {
|
|
61
|
+
readonly tag: string;
|
|
62
|
+
readonly by: string;
|
|
63
|
+
readonly atMs: number;
|
|
64
|
+
};
|
|
65
|
+
/**
|
|
66
|
+
* Folds the recorded edits (in any order) into the reviewer tags that stand now, by target (see {@link targetKey}).
|
|
67
|
+
* Edits apply oldest first, ties by `id`; a tag added, removed and added again stands, with the time and author of
|
|
68
|
+
* the last add. A target whose tags were all removed is absent. Entries that are not understood are ignored.
|
|
69
|
+
*/
|
|
70
|
+
export declare function foldArtifactTags(entries: readonly ArtifactTagEntry[]): Map<string, ReviewerTag[]>;
|
|
71
|
+
/** Where a shown tag comes from: the attachment's own producer (`artifact`), a reviewer after the upload (`reviewer`), or the check or run around it (`inherited`). */
|
|
72
|
+
export type TagSource = 'artifact' | 'reviewer' | 'inherited';
|
|
73
|
+
/** One tag an attachment shows, with its source and, for a reviewer's tag, who added it and when. */
|
|
74
|
+
export type ArtifactTag = {
|
|
75
|
+
readonly tag: string;
|
|
76
|
+
readonly source: TagSource;
|
|
77
|
+
readonly by?: string;
|
|
78
|
+
readonly atMs?: number;
|
|
79
|
+
};
|
|
80
|
+
/**
|
|
81
|
+
* The tags one attachment shows, normalized and without repeats: its producer's own (`artifact`), then the
|
|
82
|
+
* reviewers' (`reviewer`), then those of its check and run that it does not have itself (`inherited`). A tag it has
|
|
83
|
+
* more than once is shown once, by the first source above.
|
|
84
|
+
*/
|
|
85
|
+
export declare function artifactTagsOf(attachment: Pick<Attachment, 'tags'>, options?: {
|
|
86
|
+
readonly reviewer?: readonly ReviewerTag[];
|
|
87
|
+
readonly inherited?: Iterable<string>;
|
|
88
|
+
}): ArtifactTag[];
|
|
89
|
+
/**
|
|
90
|
+
* Does an attachment match a tag (either spelling)? An exact query (`inherited: false`, the default) matches only the
|
|
91
|
+
* tags the attachment has itself, from its producer or a reviewer; an inherited query also matches the tags it
|
|
92
|
+
* inherits from its check and run. The two differ for a recording that is untagged under a tagged run.
|
|
93
|
+
*/
|
|
94
|
+
export declare function hasArtifactTag(tags: readonly ArtifactTag[], wanted: string, options?: {
|
|
95
|
+
readonly inherited?: boolean;
|
|
96
|
+
}): boolean;
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/** The longest a normalized tag may be, in characters. */
|
|
2
|
+
export const MAX_TAG_LENGTH = 64;
|
|
3
|
+
/**
|
|
4
|
+
* Normalizes a tag so equal tags compare equal: Unicode NFC, any leading `#` removed, surrounding whitespace
|
|
5
|
+
* trimmed, letters lower-cased and each run of whitespace inside turned into one `-`. Every other character is kept,
|
|
6
|
+
* so `Smoke`, `#smoke` and ` smoke ` are `smoke`, and `team/payments` or `release=2` stay as they are. Returns
|
|
7
|
+
* `null` for a tag that is empty after that (`''`, `'#'`, `' '`) or longer than {@link MAX_TAG_LENGTH}.
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* ```ts
|
|
11
|
+
* normalizeTag('#Demo'); // 'demo'
|
|
12
|
+
* normalizeTag('release candidate'); // 'release-candidate'
|
|
13
|
+
* normalizeTag('#'); // null
|
|
14
|
+
* ```
|
|
15
|
+
*/
|
|
16
|
+
export function normalizeTag(raw) {
|
|
17
|
+
if (typeof raw !== 'string')
|
|
18
|
+
return null;
|
|
19
|
+
const tag = raw.normalize('NFC').trim().replace(/^#+/, '').trim().toLowerCase().replace(/\s+/g, '-');
|
|
20
|
+
return tag.length > 0 && tag.length <= MAX_TAG_LENGTH ? tag : null;
|
|
21
|
+
}
|
|
22
|
+
/** Normalizes every tag with {@link normalizeTag}, dropping the ones that are not tags and the repeats; the first of each keeps its place. */
|
|
23
|
+
export function normalizeTags(raw) {
|
|
24
|
+
const seen = new Set();
|
|
25
|
+
for (const value of raw ?? []) {
|
|
26
|
+
const tag = normalizeTag(value);
|
|
27
|
+
if (tag !== null)
|
|
28
|
+
seen.add(tag);
|
|
29
|
+
}
|
|
30
|
+
return [...seen];
|
|
31
|
+
}
|
|
32
|
+
/** The tag as a person sees it: `#demo`. */
|
|
33
|
+
export const displayTag = (tag) => `#${tag}`;
|
|
34
|
+
/**
|
|
35
|
+
* How an edit names an attachment: `id:<id>` when it has an `id`, else `key:<bundleKey>` (its bytes), else
|
|
36
|
+
* `name:<name>`. It is stable for a layer, which never changes. Two attachments in one check with the same bytes and
|
|
37
|
+
* no `id` share a reference, and so share reviewer tags; give them an `id` to tag them apart.
|
|
38
|
+
*/
|
|
39
|
+
export function artifactRef(attachment) {
|
|
40
|
+
if (attachment.id)
|
|
41
|
+
return `id:${attachment.id}`;
|
|
42
|
+
if (attachment.bundleKey)
|
|
43
|
+
return `key:${attachment.bundleKey}`;
|
|
44
|
+
return `name:${attachment.name}`;
|
|
45
|
+
}
|
|
46
|
+
/** A string that is equal for equal targets (the part `kind` is optional and then does not matter). */
|
|
47
|
+
export const targetKey = (target) => JSON.stringify([target.layer, target.check, target.kind ?? '', target.artifact]);
|
|
48
|
+
/**
|
|
49
|
+
* Folds the recorded edits (in any order) into the reviewer tags that stand now, by target (see {@link targetKey}).
|
|
50
|
+
* Edits apply oldest first, ties by `id`; a tag added, removed and added again stands, with the time and author of
|
|
51
|
+
* the last add. A target whose tags were all removed is absent. Entries that are not understood are ignored.
|
|
52
|
+
*/
|
|
53
|
+
export function foldArtifactTags(entries) {
|
|
54
|
+
const ordered = [...entries].sort((a, b) => a.atMs - b.atMs || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
55
|
+
const standing = new Map();
|
|
56
|
+
for (const entry of ordered) {
|
|
57
|
+
const key = targetKey(entry.target);
|
|
58
|
+
const tags = standing.get(key) ?? new Map();
|
|
59
|
+
for (const tag of entry.remove)
|
|
60
|
+
tags.delete(tag);
|
|
61
|
+
for (const tag of entry.add)
|
|
62
|
+
tags.set(tag, { tag, by: entry.by, atMs: entry.atMs });
|
|
63
|
+
standing.set(key, tags);
|
|
64
|
+
}
|
|
65
|
+
const out = new Map();
|
|
66
|
+
for (const [key, tags] of standing)
|
|
67
|
+
if (tags.size)
|
|
68
|
+
out.set(key, [...tags.values()]);
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* The tags one attachment shows, normalized and without repeats: its producer's own (`artifact`), then the
|
|
73
|
+
* reviewers' (`reviewer`), then those of its check and run that it does not have itself (`inherited`). A tag it has
|
|
74
|
+
* more than once is shown once, by the first source above.
|
|
75
|
+
*/
|
|
76
|
+
export function artifactTagsOf(attachment, options = {}) {
|
|
77
|
+
const out = new Map();
|
|
78
|
+
for (const tag of normalizeTags(attachment.tags))
|
|
79
|
+
out.set(tag, { tag, source: 'artifact' });
|
|
80
|
+
for (const found of options.reviewer ?? [])
|
|
81
|
+
if (!out.has(found.tag))
|
|
82
|
+
out.set(found.tag, { tag: found.tag, source: 'reviewer', by: found.by, atMs: found.atMs });
|
|
83
|
+
for (const tag of normalizeTags(options.inherited))
|
|
84
|
+
if (!out.has(tag))
|
|
85
|
+
out.set(tag, { tag, source: 'inherited' });
|
|
86
|
+
return [...out.values()];
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Does an attachment match a tag (either spelling)? An exact query (`inherited: false`, the default) matches only the
|
|
90
|
+
* tags the attachment has itself, from its producer or a reviewer; an inherited query also matches the tags it
|
|
91
|
+
* inherits from its check and run. The two differ for a recording that is untagged under a tagged run.
|
|
92
|
+
*/
|
|
93
|
+
export function hasArtifactTag(tags, wanted, options = {}) {
|
|
94
|
+
const tag = normalizeTag(wanted);
|
|
95
|
+
return tag !== null && tags.some(item => item.tag === tag && (options.inherited === true || item.source !== 'inherited'));
|
|
96
|
+
}
|
|
@@ -33,7 +33,7 @@ export type EvidenceHolder = {
|
|
|
33
33
|
*
|
|
34
34
|
* Several holders (for example a check and its layer) are summed: `attachments` also counts those on steps and
|
|
35
35
|
* attempts, `steps` counts only each holder's own top-level steps, tags are de-duplicated, and for a repeated
|
|
36
|
-
* label the later holder wins. `roles` are the attachments' roles (or, without one, what their media type or file
|
|
36
|
+
* label the later holder wins. `artifactTags` are the attachments' own normalized tags and are never inherited. `roles` are the attachments' roles (or, without one, what their media type or file
|
|
37
37
|
* name implies), sorted and distinct.
|
|
38
38
|
*/
|
|
39
39
|
export declare function summarizeEvidence(...holders: readonly EvidenceHolder[]): EvidenceSummary | undefined;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { normalizeTags } from './artifact-tags.js';
|
|
1
2
|
import { contentTypeOf } from './media-types.js';
|
|
2
3
|
function count(holder) {
|
|
3
4
|
return (holder.attachments?.length ?? 0) + (holder.steps?.length ?? 0) + (holder.annotations?.length ?? 0) + (holder.attempts?.length ?? 0) + (holder.tags?.length ?? 0) + (holder.links?.length ?? 0) + Object.keys(holder.labels ?? {}).length + Object.keys(holder.extensions ?? {}).length;
|
|
@@ -9,7 +10,7 @@ function count(holder) {
|
|
|
9
10
|
*
|
|
10
11
|
* Several holders (for example a check and its layer) are summed: `attachments` also counts those on steps and
|
|
11
12
|
* attempts, `steps` counts only each holder's own top-level steps, tags are de-duplicated, and for a repeated
|
|
12
|
-
* label the later holder wins. `roles` are the attachments' roles (or, without one, what their media type or file
|
|
13
|
+
* label the later holder wins. `artifactTags` are the attachments' own normalized tags and are never inherited. `roles` are the attachments' roles (or, without one, what their media type or file
|
|
13
14
|
* name implies), sorted and distinct.
|
|
14
15
|
*/
|
|
15
16
|
export function summarizeEvidence(...holders) {
|
|
@@ -17,10 +18,13 @@ export function summarizeEvidence(...holders) {
|
|
|
17
18
|
return undefined;
|
|
18
19
|
const roles = new Set();
|
|
19
20
|
const tags = new Set();
|
|
21
|
+
const artifactTags = new Set();
|
|
20
22
|
const labels = {};
|
|
21
23
|
let attachments = 0, steps = 0, annotations = 0, attempts = 0, links = 0;
|
|
22
24
|
const attachmentsOf = (list) => { for (const item of list ?? []) {
|
|
23
25
|
attachments += 1;
|
|
26
|
+
for (const tag of normalizeTags(item.tags))
|
|
27
|
+
artifactTags.add(tag);
|
|
24
28
|
roles.add(item.role ?? roleOfType(item.contentType ?? contentTypeOf(item.name)));
|
|
25
29
|
} };
|
|
26
30
|
for (const holder of holders) {
|
|
@@ -41,7 +45,7 @@ export function summarizeEvidence(...holders) {
|
|
|
41
45
|
tags.add(tag);
|
|
42
46
|
Object.assign(labels, holder.labels);
|
|
43
47
|
}
|
|
44
|
-
return { attachments, steps, annotations, attempts, links, tags: [...tags], labels, roles: [...roles].sort() };
|
|
48
|
+
return { attachments, steps, annotations, attempts, links, tags: [...tags], ...(artifactTags.size ? { artifactTags: [...artifactTags].sort() } : {}), labels, roles: [...roles].sort() };
|
|
45
49
|
}
|
|
46
50
|
/** A role to show when a producer gave none, from the media type: `image`, `video`, `audio`, `snapshot` (text/html), `text` (text, JSON, XML) or `file`. Roles are an open vocabulary; a producer's own role always wins. */
|
|
47
51
|
export function roleOfType(contentType) {
|
|
@@ -50,6 +50,8 @@ export type EvidenceSummary = {
|
|
|
50
50
|
readonly attempts: number;
|
|
51
51
|
readonly links: number;
|
|
52
52
|
readonly tags: readonly string[];
|
|
53
|
+
/** The normalized tags of the attachments themselves (`#demo` is `demo`), sorted and distinct. Absent when no attachment is tagged. Unlike `tags` they are never inherited from a check or run. */
|
|
54
|
+
readonly artifactTags?: readonly string[];
|
|
53
55
|
readonly labels: Readonly<Record<string, string>>;
|
|
54
56
|
/** The distinct roles of its attachments (or what their media types say), to show what kind of evidence there is. */
|
|
55
57
|
readonly roles: readonly string[];
|
|
@@ -82,6 +82,7 @@ export declare function createReportsExplorerApp(deps: ReportsExplorerDeps): imp
|
|
|
82
82
|
height?: number | undefined;
|
|
83
83
|
durationMs?: number | undefined;
|
|
84
84
|
description?: string | undefined;
|
|
85
|
+
tags?: string[] | undefined;
|
|
85
86
|
at?: {
|
|
86
87
|
[x: string]: import("hono/utils/types").JSONValue;
|
|
87
88
|
step?: string | undefined;
|
|
@@ -125,6 +126,7 @@ export declare function createReportsExplorerApp(deps: ReportsExplorerDeps): imp
|
|
|
125
126
|
height?: number | undefined;
|
|
126
127
|
durationMs?: number | undefined;
|
|
127
128
|
description?: string | undefined;
|
|
129
|
+
tags?: string[] | undefined;
|
|
128
130
|
at?: {
|
|
129
131
|
[x: string]: import("hono/utils/types").JSONValue;
|
|
130
132
|
step?: string | undefined;
|
|
@@ -181,6 +183,7 @@ export declare function createReportsExplorerApp(deps: ReportsExplorerDeps): imp
|
|
|
181
183
|
height?: number | undefined;
|
|
182
184
|
durationMs?: number | undefined;
|
|
183
185
|
description?: string | undefined;
|
|
186
|
+
tags?: string[] | undefined;
|
|
184
187
|
at?: {
|
|
185
188
|
[x: string]: import("hono/utils/types").JSONValue;
|
|
186
189
|
step?: string | undefined;
|
|
@@ -247,6 +250,7 @@ export declare function createReportsExplorerApp(deps: ReportsExplorerDeps): imp
|
|
|
247
250
|
height?: number | undefined;
|
|
248
251
|
durationMs?: number | undefined;
|
|
249
252
|
description?: string | undefined;
|
|
253
|
+
tags?: string[] | undefined;
|
|
250
254
|
at?: {
|
|
251
255
|
[x: string]: import("hono/utils/types").JSONValue;
|
|
252
256
|
step?: string | undefined;
|
|
@@ -352,6 +356,7 @@ export declare function createReportsExplorerApp(deps: ReportsExplorerDeps): imp
|
|
|
352
356
|
height?: number | undefined;
|
|
353
357
|
durationMs?: number | undefined;
|
|
354
358
|
description?: string | undefined;
|
|
359
|
+
tags?: string[] | undefined;
|
|
355
360
|
at?: {
|
|
356
361
|
[x: string]: import("hono/utils/types").JSONValue;
|
|
357
362
|
step?: string | undefined;
|
|
@@ -395,6 +400,7 @@ export declare function createReportsExplorerApp(deps: ReportsExplorerDeps): imp
|
|
|
395
400
|
height?: number | undefined;
|
|
396
401
|
durationMs?: number | undefined;
|
|
397
402
|
description?: string | undefined;
|
|
403
|
+
tags?: string[] | undefined;
|
|
398
404
|
at?: {
|
|
399
405
|
[x: string]: import("hono/utils/types").JSONValue;
|
|
400
406
|
step?: string | undefined;
|
|
@@ -451,6 +457,7 @@ export declare function createReportsExplorerApp(deps: ReportsExplorerDeps): imp
|
|
|
451
457
|
height?: number | undefined;
|
|
452
458
|
durationMs?: number | undefined;
|
|
453
459
|
description?: string | undefined;
|
|
460
|
+
tags?: string[] | undefined;
|
|
454
461
|
at?: {
|
|
455
462
|
[x: string]: import("hono/utils/types").JSONValue;
|
|
456
463
|
step?: string | undefined;
|
|
@@ -517,6 +524,7 @@ export declare function createReportsExplorerApp(deps: ReportsExplorerDeps): imp
|
|
|
517
524
|
height?: number | undefined;
|
|
518
525
|
durationMs?: number | undefined;
|
|
519
526
|
description?: string | undefined;
|
|
527
|
+
tags?: string[] | undefined;
|
|
520
528
|
at?: {
|
|
521
529
|
[x: string]: import("hono/utils/types").JSONValue;
|
|
522
530
|
step?: string | undefined;
|
|
@@ -120,6 +120,7 @@ export declare function createReportsApp(deps: ReportsAppDeps): import("hono/hon
|
|
|
120
120
|
height?: number | undefined;
|
|
121
121
|
durationMs?: number | undefined;
|
|
122
122
|
description?: string | undefined;
|
|
123
|
+
tags?: string[] | undefined;
|
|
123
124
|
at?: {
|
|
124
125
|
[x: string]: import("hono/utils/types").JSONValue;
|
|
125
126
|
step?: string | undefined;
|
|
@@ -163,6 +164,7 @@ export declare function createReportsApp(deps: ReportsAppDeps): import("hono/hon
|
|
|
163
164
|
height?: number | undefined;
|
|
164
165
|
durationMs?: number | undefined;
|
|
165
166
|
description?: string | undefined;
|
|
167
|
+
tags?: string[] | undefined;
|
|
166
168
|
at?: {
|
|
167
169
|
[x: string]: import("hono/utils/types").JSONValue;
|
|
168
170
|
step?: string | undefined;
|
|
@@ -219,6 +221,7 @@ export declare function createReportsApp(deps: ReportsAppDeps): import("hono/hon
|
|
|
219
221
|
height?: number | undefined;
|
|
220
222
|
durationMs?: number | undefined;
|
|
221
223
|
description?: string | undefined;
|
|
224
|
+
tags?: string[] | undefined;
|
|
222
225
|
at?: {
|
|
223
226
|
[x: string]: import("hono/utils/types").JSONValue;
|
|
224
227
|
step?: string | undefined;
|
|
@@ -285,6 +288,7 @@ export declare function createReportsApp(deps: ReportsAppDeps): import("hono/hon
|
|
|
285
288
|
height?: number | undefined;
|
|
286
289
|
durationMs?: number | undefined;
|
|
287
290
|
description?: string | undefined;
|
|
291
|
+
tags?: string[] | undefined;
|
|
288
292
|
at?: {
|
|
289
293
|
[x: string]: import("hono/utils/types").JSONValue;
|
|
290
294
|
step?: string | undefined;
|
|
@@ -390,6 +394,7 @@ export declare function createReportsApp(deps: ReportsAppDeps): import("hono/hon
|
|
|
390
394
|
height?: number | undefined;
|
|
391
395
|
durationMs?: number | undefined;
|
|
392
396
|
description?: string | undefined;
|
|
397
|
+
tags?: string[] | undefined;
|
|
393
398
|
at?: {
|
|
394
399
|
[x: string]: import("hono/utils/types").JSONValue;
|
|
395
400
|
step?: string | undefined;
|
|
@@ -433,6 +438,7 @@ export declare function createReportsApp(deps: ReportsAppDeps): import("hono/hon
|
|
|
433
438
|
height?: number | undefined;
|
|
434
439
|
durationMs?: number | undefined;
|
|
435
440
|
description?: string | undefined;
|
|
441
|
+
tags?: string[] | undefined;
|
|
436
442
|
at?: {
|
|
437
443
|
[x: string]: import("hono/utils/types").JSONValue;
|
|
438
444
|
step?: string | undefined;
|
|
@@ -489,6 +495,7 @@ export declare function createReportsApp(deps: ReportsAppDeps): import("hono/hon
|
|
|
489
495
|
height?: number | undefined;
|
|
490
496
|
durationMs?: number | undefined;
|
|
491
497
|
description?: string | undefined;
|
|
498
|
+
tags?: string[] | undefined;
|
|
492
499
|
at?: {
|
|
493
500
|
[x: string]: import("hono/utils/types").JSONValue;
|
|
494
501
|
step?: string | undefined;
|
|
@@ -555,6 +562,7 @@ export declare function createReportsApp(deps: ReportsAppDeps): import("hono/hon
|
|
|
555
562
|
height?: number | undefined;
|
|
556
563
|
durationMs?: number | undefined;
|
|
557
564
|
description?: string | undefined;
|
|
565
|
+
tags?: string[] | undefined;
|
|
558
566
|
at?: {
|
|
559
567
|
[x: string]: import("hono/utils/types").JSONValue;
|
|
560
568
|
step?: string | undefined;
|
|
@@ -1,25 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @module Test catalog, history and runs
|
|
3
|
-
*
|
|
4
|
-
* What a person or an agent asks of a report once it holds more than a handful of checks: every test with where it ran,
|
|
5
|
-
* how it went over time and how long it takes; the specs, domains, environments and producers they fall under; the CI
|
|
6
|
-
* runs that produced them and which tests each one ran.
|
|
7
|
-
*
|
|
8
|
-
* - `TestHistory` keeps every recorded result of every test (bounded per test) and every run's results, folded in one
|
|
9
|
-
* layer at a time, in any order, with the same outcome. It never decides a status: the current status of a test is
|
|
10
|
-
* always the report's winner (`combineEvidence`), and history only describes how it got there.
|
|
11
|
-
* - `testCatalog` turns the report's winners (plus a history, when there is one) into a `TestCatalog`: a summary with
|
|
12
|
-
* insights (slowest, flaky, failing), facets to filter by, and the tests that match a `TestQuery`.
|
|
13
|
-
* - `testHistory`, `runList` and `runDetail` answer for one test, every run, and one run.
|
|
14
|
-
* - `catalogMarkdown`, `historyMarkdown`, `runsMarkdown` and `runMarkdown` say the same in Markdown, for an agent or a
|
|
15
|
-
* terminal.
|
|
16
|
-
*
|
|
17
|
-
* A test is a check identity (its spec path and id) regardless of producer, environment and parameters; each of those
|
|
18
|
-
* combinations is one of its results. A result that is not `passed` is never counted as a pass, and a test with no
|
|
19
|
-
* completed result has no pass rate.
|
|
20
|
-
*
|
|
21
|
-
* @see docs/server.md#reading-the-report-as-an-agent
|
|
22
|
-
*/
|
|
23
1
|
import type { EvidenceStatus, SpecEntry } from './explorer-types.js';
|
|
24
2
|
import type { EvidenceSummary, LayerEvidence, LayerHeader, LayerRow, StatusCounts } from './evidence-types.js';
|
|
25
3
|
import { RecordedSpecs } from './recorded-specs.js';
|
|
@@ -116,6 +94,10 @@ export type TestSummary = {
|
|
|
116
94
|
readonly outcomes: readonly TestOutcome[];
|
|
117
95
|
readonly stats: TestStats;
|
|
118
96
|
readonly tags: readonly string[];
|
|
97
|
+
/** The normalized tags of the attachments of its current results (`demo`, shown as `#demo`). Never inherited from a check or run; the `tags` above are. Absent in documents of older servers. */
|
|
98
|
+
readonly artifactTags?: readonly string[];
|
|
99
|
+
/** The normalized tags its attachments inherit from their check and run (those of the results that have attachments), apart from `artifactTags`. What an inherited artifact query also matches. */
|
|
100
|
+
readonly inheritedArtifactTags?: readonly string[];
|
|
119
101
|
readonly labels: Readonly<Record<string, string>>;
|
|
120
102
|
/** What its current results carry: attachments, steps, retries, and the kinds of attachment. */
|
|
121
103
|
readonly evidence: {
|
|
@@ -187,6 +169,8 @@ export type CatalogFacets = {
|
|
|
187
169
|
readonly counts: StatusCounts;
|
|
188
170
|
})[];
|
|
189
171
|
readonly tags: readonly FacetValue[];
|
|
172
|
+
/** The tags of attachments, normalized. Absent in documents of older servers. */
|
|
173
|
+
readonly artifactTags?: readonly FacetValue[];
|
|
190
174
|
readonly branches: readonly FacetValue[];
|
|
191
175
|
readonly labels: Readonly<Record<string, readonly FacetValue[]>>;
|
|
192
176
|
};
|
|
@@ -206,6 +190,10 @@ export type TestQuery = {
|
|
|
206
190
|
/** Spec paths (`specs/...`). */
|
|
207
191
|
readonly spec?: readonly string[];
|
|
208
192
|
readonly tag?: readonly string[];
|
|
193
|
+
/** Only tests with an attachment carrying this tag (`demo` and `#demo` are the same), not counting tags inherited from a check or run: the exact artifact query. `tag` is the inherited one. */
|
|
194
|
+
readonly artifactTag?: readonly string[];
|
|
195
|
+
/** With `artifactTag`: also match the tags an attachment inherits from its check and run (a recording without a tag of its own under a run tagged `demo`). Default false: the exact query. */
|
|
196
|
+
readonly inherited?: boolean;
|
|
209
197
|
/** Only tests recorded in this run (a key from `runList`). */
|
|
210
198
|
readonly run?: string;
|
|
211
199
|
readonly sort?: TestSort;
|
|
@@ -396,7 +384,7 @@ export declare function testHistory(model: CatalogModel, key: string): TestHisto
|
|
|
396
384
|
export declare function runList(history: TestHistory, version: string, limit?: number): RunList;
|
|
397
385
|
/** One run with every result it recorded, or null when no run has that key. */
|
|
398
386
|
export declare function runDetail(history: TestHistory, version: string, key: string): RunDetail | null;
|
|
399
|
-
/** Reads a `TestQuery` from URL search parameters: `q`, `sort`, `offset`, `limit`, `run`, and repeatable or comma-separated `status`, `environment`, `producer`, `domain`, `spec`, `tag`. */
|
|
387
|
+
/** Reads a `TestQuery` from URL search parameters: `q`, `sort`, `offset`, `limit`, `run`, and repeatable or comma-separated `status`, `environment`, `producer`, `domain`, `spec`, `tag`, `artifactTag`, and `inherited=1`. */
|
|
400
388
|
export declare function parseTestQuery(params: URLSearchParams): TestQuery;
|
|
401
389
|
/** A duration for a person: `850 ms`, `12.3 s`, `4m 05s`. */
|
|
402
390
|
export declare function formatMs(ms: number | null | undefined): string;
|