@crvouga/atlas 0.13.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 +12 -0
- package/dist/playwright-reporter/src/index.d.ts +11 -0
- package/dist/playwright-reporter/src/index.js +4 -2
- 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 +5 -0
- package/dist/reports/src/layer-merge.d.ts +1 -1
- package/dist/reports/src/layer-merge.js +3 -2
- package/dist/reports/src/layer-types.d.ts +3 -0
- package/dist/reports/src/publish-checks.js +1 -0
- package/dist/reports/src/report-cli.d.ts +1 -1
- package/dist/reports/src/report-cli.js +30 -2
- package/dist/reports/src/reporter.d.ts +1 -1
- 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 +31 -27
- package/dist/reports/src/test-catalog.js +73 -12
- package/dist/schema/src/api.d.ts +128 -1
- package/dist/schema/src/api.js +38 -4
- package/dist/schema/src/layer.d.ts +59 -0
- package/dist/schema/src/layer.js +28 -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/integrating.md +8 -0
- package/docs/reference/cli.md +7 -0
- package/docs/reference/http-api.md +48 -0
- package/docs/reference/playwright-reporter.md +6 -6
- package/docs/reference/reports.md +235 -8
- package/docs/reference/schema.md +52 -1
- package/docs/report-contract.md +1 -0
- package/docs/rich-evidence.md +28 -0
- package/docs/server.md +24 -0
- package/llms-full.txt +119 -0
- package/package.json +1 -1
- package/packages/schema/schemas/check-detail.schema.json +28 -0
- package/packages/schema/schemas/effective-delta.schema.json +44 -0
- package/packages/schema/schemas/effective-evidence.schema.json +44 -0
- package/packages/schema/schemas/evidence-page.schema.json +37 -0
- package/packages/schema/schemas/layer-rows.schema.json +7 -0
- package/packages/schema/schemas/layer.schema.json +100 -0
- package/packages/schema/schemas/publish-checks.schema.json +65 -0
- package/packages/schema/schemas/report-run.schema.json +17 -1
- package/packages/schema/schemas/report-runs.schema.json +17 -1
- 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 +886 -3
- package/packages/visualizer/src/evidence/artifact-tags.tsx +285 -0
- package/packages/visualizer/src/evidence/artifacts.tsx +18 -12
- package/packages/visualizer/src/explorer/RunsView.tsx +23 -4
- package/packages/visualizer/src/explorer/parts.tsx +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
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
|
+
|
|
9
|
+
## 0.14.0 (2026-10-28)
|
|
10
|
+
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
- identify local demo executions separately from branch and commit ([543bf9f](https://github.com/crvouga/atlas/commit/543bf9f00bacb3deb5d32d78c3b61437e04497df))
|
|
14
|
+
|
|
3
15
|
## 0.13.0 (2026-10-28)
|
|
4
16
|
|
|
5
17
|
### Features
|
|
@@ -35,6 +35,17 @@ export type AtlasPlaywrightReporterOptions = {
|
|
|
35
35
|
readonly branch?: string;
|
|
36
36
|
/** Where to read the CI run. Default the GitHub Actions run (or the CI's own run URL variable). */
|
|
37
37
|
readonly runUrl?: string;
|
|
38
|
+
/**
|
|
39
|
+
* Names this execution, for a local or manual run with no CI run URL: every shard and project that gets the same `id`
|
|
40
|
+
* is one run, and the next invocation gets another. Default: `ATLAS_RUN_ID` (and `ATLAS_RUN_TITLE`) when set. Only
|
|
41
|
+
* groups and labels runs; it never changes what a check is or which result wins.
|
|
42
|
+
*/
|
|
43
|
+
readonly run?: {
|
|
44
|
+
readonly id: string;
|
|
45
|
+
readonly title?: string;
|
|
46
|
+
readonly startedAtMs?: number;
|
|
47
|
+
readonly endedAtMs?: number;
|
|
48
|
+
};
|
|
38
49
|
/** The spec a test belongs to, relative to the repository. Default: the `.feature` of a playwright-bdd test, else the test file. */
|
|
39
50
|
readonly specPath?: (test: TestCase) => string | undefined;
|
|
40
51
|
/** Paths are relative to this directory. Default the git root above Playwright's `rootDir`, else `rootDir`. */
|
|
@@ -123,15 +123,16 @@ export default class AtlasPlaywrightReporter {
|
|
|
123
123
|
}
|
|
124
124
|
if (!this.store)
|
|
125
125
|
return;
|
|
126
|
+
const run = options.run ?? (env.ATLAS_RUN_ID ? { id: env.ATLAS_RUN_ID, ...(env.ATLAS_RUN_TITLE ? { title: env.ATLAS_RUN_TITLE } : {}) } : undefined);
|
|
126
127
|
try {
|
|
127
128
|
const explicitSha = options.sha ?? env.ATLAS_SHA;
|
|
128
129
|
const origin = detectOrigin(env);
|
|
129
130
|
const sha = explicitSha ?? origin.sha;
|
|
130
131
|
const freshness = detectFreshness({ sha, ci: origin.ci && !explicitSha });
|
|
131
|
-
this.identity = { sha, branch: options.branch ?? env.ATLAS_BRANCH ?? origin.branch, runUrl: options.runUrl ?? origin.runUrl, ...(freshness ? { freshness } : {}) };
|
|
132
|
+
this.identity = { sha, branch: options.branch ?? env.ATLAS_BRANCH ?? origin.branch, runUrl: options.runUrl ?? origin.runUrl, ...(run ? { run } : {}), ...(freshness ? { freshness } : {}) };
|
|
132
133
|
}
|
|
133
134
|
catch {
|
|
134
|
-
this.identity = { sha: options.sha ?? env.ATLAS_SHA ?? null, branch: options.branch ?? env.ATLAS_BRANCH ?? null, runUrl: options.runUrl ?? null, freshness: 'unknown' };
|
|
135
|
+
this.identity = { sha: options.sha ?? env.ATLAS_SHA ?? null, branch: options.branch ?? env.ATLAS_BRANCH ?? null, runUrl: options.runUrl ?? null, ...(run ? { run } : {}), freshness: 'unknown' };
|
|
135
136
|
}
|
|
136
137
|
}
|
|
137
138
|
specPathOf(test) {
|
|
@@ -195,6 +196,7 @@ export default class AtlasPlaywrightReporter {
|
|
|
195
196
|
sha: this.identity.sha,
|
|
196
197
|
branch: this.identity.branch,
|
|
197
198
|
runUrl: this.identity.runUrl,
|
|
199
|
+
...(this.identity.run ? { run: this.identity.run } : {}),
|
|
198
200
|
...(this.identity.freshness ? { freshness: this.identity.freshness } : {}),
|
|
199
201
|
environment,
|
|
200
202
|
parameters: { ...parameters },
|
|
@@ -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) {
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
* @see docs/report-contract.md
|
|
10
10
|
*/
|
|
11
11
|
import type { EvidenceMeasurement, EvidenceStatus } from './explorer-types.js';
|
|
12
|
+
import type { LayerRun } from '../../schema/src/layer.js';
|
|
12
13
|
import type { LayerParameters } from './layer-types.js';
|
|
13
14
|
/** Where a recorded test's timeline and captured files are read from. */
|
|
14
15
|
export type RecordingAddress = {
|
|
@@ -49,6 +50,8 @@ export type EvidenceSummary = {
|
|
|
49
50
|
readonly attempts: number;
|
|
50
51
|
readonly links: number;
|
|
51
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[];
|
|
52
55
|
readonly labels: Readonly<Record<string, string>>;
|
|
53
56
|
/** The distinct roles of its attachments (or what their media types say), to show what kind of evidence there is. */
|
|
54
57
|
readonly roles: readonly string[];
|
|
@@ -77,6 +80,8 @@ export type LayerHeader = {
|
|
|
77
80
|
readonly parameters: LayerParameters;
|
|
78
81
|
/** `modified`: recorded from a checkout with uncommitted changes; `unknown`: origin not captured; absent: clean. Advisory; never changes a status. */
|
|
79
82
|
readonly freshness?: 'unknown' | 'modified';
|
|
83
|
+
/** The execution the producer named for it (one local or manual invocation), or absent. Grouping and display only; never part of the slot. */
|
|
84
|
+
readonly run?: LayerRun;
|
|
80
85
|
/** Where this layer's recorded timelines and captured files are read from, or null when it has none. */
|
|
81
86
|
readonly recording: RecordingAddress | null;
|
|
82
87
|
/** From a spec run that has not been collected into a layer yet. */
|
|
@@ -129,7 +129,7 @@ export declare function distinctEvidence(layers: readonly LayerEvidence[]): Laye
|
|
|
129
129
|
/** Drops each mutable layer whose execution is already archived as an immutable one, and keeps one summary per id. */
|
|
130
130
|
export declare function distinctLayers(layers: readonly LayerSummary[]): LayerSummary[];
|
|
131
131
|
/**
|
|
132
|
-
* A string equal for the mutable and the archived address of one execution: its producer, sha, time and
|
|
132
|
+
* A string equal for the mutable and the archived address of one execution: its producer, sha, time, parts and, when the producer named one, its execution id.
|
|
133
133
|
* Object key order is ignored at every depth, including check evidence and extensions; array order is preserved.
|
|
134
134
|
* This is an advisory execution identity, not the content digest that addresses an immutable layer in storage.
|
|
135
135
|
*/
|
|
@@ -97,6 +97,7 @@ export function layerEvidence(summary, backend) {
|
|
|
97
97
|
const layerSummaryEvidence = summarizeEvidence(summary, ...summary.parts);
|
|
98
98
|
const layer = {
|
|
99
99
|
...(summary.freshness ? { freshness: summary.freshness } : {}),
|
|
100
|
+
...(summary.run ? { run: summary.run } : {}),
|
|
100
101
|
id: summary.id,
|
|
101
102
|
backend,
|
|
102
103
|
producer: summary.producer,
|
|
@@ -270,12 +271,12 @@ export function distinctLayers(layers) {
|
|
|
270
271
|
return [...new Map(layers.filter(layer => archived(layer.id) || !kept.has(layerExecutionKey(layer))).map(layer => [layer.id, layer])).values()];
|
|
271
272
|
}
|
|
272
273
|
/**
|
|
273
|
-
* A string equal for the mutable and the archived address of one execution: its producer, sha, time and
|
|
274
|
+
* A string equal for the mutable and the archived address of one execution: its producer, sha, time, parts and, when the producer named one, its execution id.
|
|
274
275
|
* Object key order is ignored at every depth, including check evidence and extensions; array order is preserved.
|
|
275
276
|
* This is an advisory execution identity, not the content digest that addresses an immutable layer in storage.
|
|
276
277
|
*/
|
|
277
278
|
export function layerExecutionKey(layer) {
|
|
278
|
-
return JSON.stringify([layer.producer, layer.sha, layer.recordedAtMs, orderedValue(layer.parts)]);
|
|
279
|
+
return JSON.stringify([layer.producer, layer.sha, layer.recordedAtMs, orderedValue(layer.parts), ...(layer.run ? [layer.run.id] : [])]);
|
|
279
280
|
}
|
|
280
281
|
/** Sort JSON objects recursively without changing array order or interpreting extension keys as prototypes. */
|
|
281
282
|
function orderedValue(value) {
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
*
|
|
9
9
|
* @see docs/report-contract.md#the-layer-document
|
|
10
10
|
*/
|
|
11
|
+
import type { LayerRun } from '../../schema/src/layer.js';
|
|
11
12
|
import type { BundleIndex } from './bundle.js';
|
|
12
13
|
import type { ReportPart, SpecEntry } from './explorer-types.js';
|
|
13
14
|
import type { SourceRecord } from './protocol.js';
|
|
@@ -33,6 +34,8 @@ export type LayerSummary = LayerExtras & {
|
|
|
33
34
|
readonly environment?: string | null;
|
|
34
35
|
/** Provenance: the CI run that produced the layer, or `null`. Never part of the slot. */
|
|
35
36
|
readonly runUrl: string | null;
|
|
37
|
+
/** The execution (one invocation of a local or manual run) the layer belongs to, when the producer named one. Grouping and display only; never part of the slot. Absent: the run is told by `runUrl`, else `branch@sha`. */
|
|
38
|
+
readonly run?: LayerRun;
|
|
36
39
|
/** Free choices that make a result its own slot. The `environment` parameter is read as the environment (see `layerPlace`). */
|
|
37
40
|
readonly parameters: LayerParameters;
|
|
38
41
|
/** The specs the layer was recorded against, with fingerprints of their text. */
|
|
@@ -103,6 +103,7 @@ export async function publishChecks(store, input, options = {}) {
|
|
|
103
103
|
sha: args.sha,
|
|
104
104
|
branch: args.branch,
|
|
105
105
|
runUrl: args.runUrl,
|
|
106
|
+
...(args.run ? { run: args.run } : {}),
|
|
106
107
|
...(environment ? { environment } : {}),
|
|
107
108
|
parameters: Object.fromEntries(Object.entries(parameters).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))),
|
|
108
109
|
specs: args.specs,
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* @see docs/agents.md
|
|
14
14
|
*/
|
|
15
15
|
/** The text `atlas report --help` prints: every subcommand, its flags, what each does and the exit codes. Also the source of the command reference in `docs/reference/cli.md`. */
|
|
16
|
-
export declare const REPORT_USAGE = "atlas report \u2014 layered evidence for any kind of spec\n\n atlas report publish [--store <dir|s3://bucket/prefix>] [--producer <name>]\n ( --ctrf <file> | --junit <file> | --manifest <file> | --checks <file.json> )\n [--environment <name>] [--branch <b>] [--sha <s>] [--run-url <u>]\n [--spec <path> | --specs <dir>] [--upload-run]\n [--param key=value]... [--kind <k>] [--title <t>] [--recorded-at <iso|ms>]\n [--attach [role=]<file>]... [--label key=value]... [--link [name=]<url>]...\n [--attach-root <dir>]... [--max-attachment-mb <n>] [--max-total-attachment-mb <n>]\n atlas report stream [--store <dir|s3://\u2026|https://atlas.example.com>] --producer <name> [--environment <name>]\n [--branch <b>] [--sha <s>] [--run-url <u>] [--kind <k>] [--param key=value]...\n [--flush-ms 1000] [--batch 100] (checks as JSON lines on standard input)\n atlas report status [--store <dir|s3://\u2026|id=url>]... [--backends <module>]\n [--specs <dir>] [--format text|json|markdown]\n [--require-environments a,b] [--require-all-specs]\n [--branch <name>]... [--ignore-modified]\n atlas report merge [--store \u2026]... [--backends <module>] --into <dir|s3://\u2026>\n atlas report doctor [--store \u2026]... [--role publisher|reader|both]\n atlas report policy --store s3://bucket/prefix [--role publisher|reader|both]\n\n A store is a directory or s3://bucket/prefix (add ?region=\u2026&endpoint=\u2026 for R2, MinIO and the\n rest). Select a project on an Atlas node with --project <slug> or ATLAS_PROJECT. This scopes specs, reports and recordings together.\n When no --store is given, ATLAS_STORE is used. Keys never go in the URL: set\n AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY, or sign in with `aws sso login`, or use a role.\n\n publish Write one immutable layer: the checks of one producer, on one environment. Safe to repeat.\n --producer is required, unless the --checks file names one\n --spec <path> every check in the report belongs to this spec (a report per feature file)\n --specs <dir> link checks to the spec files in <dir> by path, or by the suite, class or\n file name the report gives; also publishes the specs themselves, so the\n node can show them without a checkout\n --upload-run with --manifest: also upload the run's screenshots and clips for the viewer\n --attach <file> attach a file to the whole layer: a CI log, a coverage report, a trace archive\n (screenshots, videos and traces of a single test go in its CTRF or checks file)\n --label k=v a fact about the run, not part of what it checks: build, os, browser\n --link [n=]<url> a link to the run, a dashboard, an issue\n Files a report names (CTRF attachments, JUnit [[ATTACHMENT|path]] lines) come from the report's own\n folder and each --attach-root, and never from a hidden file or a link out: a report is data from a\n test run, not instructions. One that is missing or not allowed is skipped with a warning.\n From a checkout with uncommitted changes the layer is marked \"modified\".\n stream Send checks as they finish: one JSON object per line on standard input (the fields of one\n check: id, title, status, and any evidence). Each second's worth becomes one layer, so a server\n shows the run while it is still going. A check sent again with the same id (running, then\n passed) is replaced by the later. Ends when the input does; exit 1 if anything could not be sent.\n Point it at an Atlas server with --store https://\u2026 and a token in ATLAS_TOKEN.\n status Combine every layer of every store, and exit 1 unless the report passes.\n --specs <dir> lay the results over these spec files (default: the specs in the store)\n --require-all-specs fail while any spec has no results\n --branch <name> only layers recorded on this branch (repeatable)\n --ignore-modified leave out layers from modified checkouts and unknown origins\n merge Copy the layers of several stores into one.\n doctor Check that a store works: credentials, listing, writing, and what it holds.\n policy Print the least-privilege IAM policy for publishers, readers, or both.\n\n --backends <module> A TypeScript or JavaScript module whose default export is a list of\n { id, label?, store } (or a function returning it): a device lab, anything\n that implements ObjectStore.\n\n --format json (alias --json) publish, stream, merge, doctor and status print one JSON document instead of prose\n (schemas: publish-result, stream-result, merge-result, doctor-result, report-status). A command\n that is wrong is {\"ok\":false,\"error\":{\"message\":\u2026,\"code\":\"usage\"}} on standard error.\n A store may be an Atlas server (https://\u2026): ATLAS_STORE=https://atlas.example.com, with ATLAS_TOKEN.\n\n Exit codes: 0 ok, 1 the report does not pass (or a check failed), 2 the command was wrong.\n";
|
|
16
|
+
export declare const REPORT_USAGE = "atlas report \u2014 layered evidence for any kind of spec\n\n atlas report publish [--store <dir|s3://bucket/prefix>] [--producer <name>]\n ( --ctrf <file> | --junit <file> | --manifest <file> | --checks <file.json> )\n [--environment <name>] [--branch <b>] [--sha <s>] [--run-url <u>]\n [--run-id <id>] [--run-title <t>] [--run-started-at <iso|ms>] [--run-ended-at <iso|ms>]\n [--spec <path> | --specs <dir>] [--upload-run]\n [--param key=value]... [--kind <k>] [--title <t>] [--recorded-at <iso|ms>]\n [--attach [role=]<file>]... [--label key=value]... [--link [name=]<url>]...\n [--attach-root <dir>]... [--max-attachment-mb <n>] [--max-total-attachment-mb <n>]\n atlas report stream [--store <dir|s3://\u2026|https://atlas.example.com>] --producer <name> [--environment <name>]\n [--branch <b>] [--sha <s>] [--run-url <u>] [--kind <k>] [--param key=value]...\n [--run-id <id>] [--run-title <t>]\n [--flush-ms 1000] [--batch 100] (checks as JSON lines on standard input)\n atlas report status [--store <dir|s3://\u2026|id=url>]... [--backends <module>]\n [--specs <dir>] [--format text|json|markdown]\n [--require-environments a,b] [--require-all-specs]\n [--branch <name>]... [--ignore-modified]\n atlas report merge [--store \u2026]... [--backends <module>] --into <dir|s3://\u2026>\n atlas report doctor [--store \u2026]... [--role publisher|reader|both]\n atlas report policy --store s3://bucket/prefix [--role publisher|reader|both]\n\n A store is a directory or s3://bucket/prefix (add ?region=\u2026&endpoint=\u2026 for R2, MinIO and the\n rest). Select a project on an Atlas node with --project <slug> or ATLAS_PROJECT. This scopes specs, reports and recordings together.\n When no --store is given, ATLAS_STORE is used. Keys never go in the URL: set\n AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY, or sign in with `aws sso login`, or use a role.\n\n publish Write one immutable layer: the checks of one producer, on one environment. Safe to repeat.\n --producer is required, unless the --checks file names one\n --spec <path> every check in the report belongs to this spec (a report per feature file)\n --specs <dir> link checks to the spec files in <dir> by path, or by the suite, class or\n file name the report gives; also publishes the specs themselves, so the\n node can show them without a checkout\n --run-id <id> name this execution (a local or manual run with no CI run URL; also ATLAS_RUN_ID).\n Layers that share an id are one run, however many shards or batches uploaded them;\n another invocation at the same commit gets another id. Never part of what a check is\n --run-title <t> a human name for the run (also ATLAS_RUN_TITLE); --run-started-at and --run-ended-at\n record when it ran. Without --run-id a run is its CI run URL, else branch@sha\n --upload-run with --manifest: also upload the run's screenshots and clips for the viewer\n --attach <file> attach a file to the whole layer: a CI log, a coverage report, a trace archive\n (screenshots, videos and traces of a single test go in its CTRF or checks file)\n --label k=v a fact about the run, not part of what it checks: build, os, browser\n --link [n=]<url> a link to the run, a dashboard, an issue\n Files a report names (CTRF attachments, JUnit [[ATTACHMENT|path]] lines) come from the report's own\n folder and each --attach-root, and never from a hidden file or a link out: a report is data from a\n test run, not instructions. One that is missing or not allowed is skipped with a warning.\n From a checkout with uncommitted changes the layer is marked \"modified\".\n stream Send checks as they finish: one JSON object per line on standard input (the fields of one\n check: id, title, status, and any evidence). Each second's worth becomes one layer, so a server\n shows the run while it is still going. A check sent again with the same id (running, then\n passed) is replaced by the later. Ends when the input does; exit 1 if anything could not be sent.\n Point it at an Atlas server with --store https://\u2026 and a token in ATLAS_TOKEN.\n status Combine every layer of every store, and exit 1 unless the report passes.\n --specs <dir> lay the results over these spec files (default: the specs in the store)\n --require-all-specs fail while any spec has no results\n --branch <name> only layers recorded on this branch (repeatable)\n --ignore-modified leave out layers from modified checkouts and unknown origins\n merge Copy the layers of several stores into one.\n doctor Check that a store works: credentials, listing, writing, and what it holds.\n policy Print the least-privilege IAM policy for publishers, readers, or both.\n\n --backends <module> A TypeScript or JavaScript module whose default export is a list of\n { id, label?, store } (or a function returning it): a device lab, anything\n that implements ObjectStore.\n\n --format json (alias --json) publish, stream, merge, doctor and status print one JSON document instead of prose\n (schemas: publish-result, stream-result, merge-result, doctor-result, report-status). A command\n that is wrong is {\"ok\":false,\"error\":{\"message\":\u2026,\"code\":\"usage\"}} on standard error.\n A store may be an Atlas server (https://\u2026): ATLAS_STORE=https://atlas.example.com, with ATLAS_TOKEN.\n\n Exit codes: 0 ok, 1 the report does not pass (or a check failed), 2 the command was wrong.\n";
|
|
17
17
|
type Env = Readonly<Record<string, string | undefined>>;
|
|
18
18
|
type Git = (args: readonly string[]) => string | null;
|
|
19
19
|
/** What `reportMain` is given besides arguments: where output goes (`out`), and, for tests and embedding, the environment, working directory, git, clock and standard input (`stdin`, where `stream` reads checks from). Defaults are the real ones. */
|
|
@@ -38,12 +38,14 @@ export const REPORT_USAGE = `atlas report — layered evidence for any kind of s
|
|
|
38
38
|
atlas report publish [--store <dir|s3://bucket/prefix>] [--producer <name>]
|
|
39
39
|
( --ctrf <file> | --junit <file> | --manifest <file> | --checks <file.json> )
|
|
40
40
|
[--environment <name>] [--branch <b>] [--sha <s>] [--run-url <u>]
|
|
41
|
+
[--run-id <id>] [--run-title <t>] [--run-started-at <iso|ms>] [--run-ended-at <iso|ms>]
|
|
41
42
|
[--spec <path> | --specs <dir>] [--upload-run]
|
|
42
43
|
[--param key=value]... [--kind <k>] [--title <t>] [--recorded-at <iso|ms>]
|
|
43
44
|
[--attach [role=]<file>]... [--label key=value]... [--link [name=]<url>]...
|
|
44
45
|
[--attach-root <dir>]... [--max-attachment-mb <n>] [--max-total-attachment-mb <n>]
|
|
45
46
|
atlas report stream [--store <dir|s3://…|https://atlas.example.com>] --producer <name> [--environment <name>]
|
|
46
47
|
[--branch <b>] [--sha <s>] [--run-url <u>] [--kind <k>] [--param key=value]...
|
|
48
|
+
[--run-id <id>] [--run-title <t>]
|
|
47
49
|
[--flush-ms 1000] [--batch 100] (checks as JSON lines on standard input)
|
|
48
50
|
atlas report status [--store <dir|s3://…|id=url>]... [--backends <module>]
|
|
49
51
|
[--specs <dir>] [--format text|json|markdown]
|
|
@@ -64,6 +66,11 @@ export const REPORT_USAGE = `atlas report — layered evidence for any kind of s
|
|
|
64
66
|
--specs <dir> link checks to the spec files in <dir> by path, or by the suite, class or
|
|
65
67
|
file name the report gives; also publishes the specs themselves, so the
|
|
66
68
|
node can show them without a checkout
|
|
69
|
+
--run-id <id> name this execution (a local or manual run with no CI run URL; also ATLAS_RUN_ID).
|
|
70
|
+
Layers that share an id are one run, however many shards or batches uploaded them;
|
|
71
|
+
another invocation at the same commit gets another id. Never part of what a check is
|
|
72
|
+
--run-title <t> a human name for the run (also ATLAS_RUN_TITLE); --run-started-at and --run-ended-at
|
|
73
|
+
record when it ran. Without --run-id a run is its CI run URL, else branch@sha
|
|
67
74
|
--upload-run with --manifest: also upload the run's screenshots and clips for the viewer
|
|
68
75
|
--attach <file> attach a file to the whole layer: a CI log, a coverage report, a trace archive
|
|
69
76
|
(screenshots, videos and traces of a single test go in its CTRF or checks file)
|
|
@@ -151,15 +158,32 @@ function linkSpec(spec) {
|
|
|
151
158
|
const named = /^([^=/:]+)=(.+)$/.exec(spec);
|
|
152
159
|
return named ? { name: named[1], url: named[2] } : { url: spec };
|
|
153
160
|
}
|
|
154
|
-
function parseTime(text) {
|
|
161
|
+
function parseTime(text, flag = '--recorded-at') {
|
|
155
162
|
if (text === undefined)
|
|
156
163
|
return null;
|
|
157
164
|
const numeric = Number(text);
|
|
158
165
|
const parsed = Number.isFinite(numeric) ? numeric : Date.parse(text);
|
|
159
166
|
if (!Number.isFinite(parsed) || parsed < 0)
|
|
160
|
-
throw new Error(
|
|
167
|
+
throw new Error(`${flag} takes an ISO date or epoch milliseconds (got "${text}")`);
|
|
161
168
|
return parsed;
|
|
162
169
|
}
|
|
170
|
+
/**
|
|
171
|
+
* The execution the producer named, from `--run-id`, `--run-title`, `--run-started-at`, `--run-ended-at` and then
|
|
172
|
+
* `ATLAS_RUN_ID` / `ATLAS_RUN_TITLE`, laid over a `run` the checks file gave. `undefined` when nothing names one, so the
|
|
173
|
+
* documented grouping (CI run URL, else branch@sha) applies. A title or time without an id is refused: it would have nothing to belong to.
|
|
174
|
+
*/
|
|
175
|
+
function executionOf(rest, env, given) {
|
|
176
|
+
const file = typeof given === 'object' && given !== null && !Array.isArray(given) ? given : {};
|
|
177
|
+
const id = value(rest, 'run-id') ?? env.ATLAS_RUN_ID ?? (typeof file.id === 'string' ? file.id : undefined);
|
|
178
|
+
const title = value(rest, 'run-title') ?? env.ATLAS_RUN_TITLE;
|
|
179
|
+
const startedAtMs = parseTime(value(rest, 'run-started-at'), '--run-started-at');
|
|
180
|
+
const endedAtMs = parseTime(value(rest, 'run-ended-at'), '--run-ended-at');
|
|
181
|
+
if (id === undefined && (title !== undefined || startedAtMs !== null || endedAtMs !== null))
|
|
182
|
+
throw new Error('--run-title, --run-started-at and --run-ended-at go with --run-id <id> (or ATLAS_RUN_ID): a run needs an identity first');
|
|
183
|
+
if (id === undefined)
|
|
184
|
+
return undefined;
|
|
185
|
+
return { ...file, id, ...(title !== undefined ? { title } : {}), ...(startedAtMs !== null ? { startedAtMs } : {}), ...(endedAtMs !== null ? { endedAtMs } : {}) };
|
|
186
|
+
}
|
|
163
187
|
function ingest(argv, base) {
|
|
164
188
|
const read = (file) => readFileSync(path.resolve(base, file), 'utf8');
|
|
165
189
|
const sources = ['ctrf', 'junit', 'manifest', 'checks'].filter(name => value(argv, name) !== undefined);
|
|
@@ -258,6 +282,7 @@ export async function reportMain(argv, io = { out: text => process.stdout.write(
|
|
|
258
282
|
}
|
|
259
283
|
const labels = { ...(ingested.labels ?? {}), ...(extra.labels ?? {}), ...Object.fromEntries(values(rest, 'label').map(pair => keyValue(pair, '--label'))) };
|
|
260
284
|
const links = [...(extra.links ?? []), ...values(rest, 'link').map(linkSpec)];
|
|
285
|
+
const execution = executionOf(rest, env, extra.run);
|
|
261
286
|
const layer = await publishChecks(target.store, {
|
|
262
287
|
...extra,
|
|
263
288
|
producer,
|
|
@@ -272,6 +297,7 @@ export async function reportMain(argv, io = { out: text => process.stdout.write(
|
|
|
272
297
|
...(value(rest, 'title') ?? extra.title ? { title: (value(rest, 'title') ?? extra.title) } : {}),
|
|
273
298
|
sha, branch,
|
|
274
299
|
runUrl: value(rest, 'run-url') ?? extra.runUrl ?? origin.runUrl,
|
|
300
|
+
...(execution ? { run: execution } : {}),
|
|
275
301
|
...(freshness ? { freshness } : {}),
|
|
276
302
|
...(value(rest, 'environment') ?? extra.environment ? { environment: (value(rest, 'environment') ?? extra.environment) } : {}),
|
|
277
303
|
parameters: { ...(extra.parameters ?? {}), ...parameters }
|
|
@@ -329,6 +355,7 @@ export async function reportMain(argv, io = { out: text => process.stdout.write(
|
|
|
329
355
|
const branch = value(rest, 'branch') ?? origin.branch;
|
|
330
356
|
const freshness = detectFreshness({ sha, ci: origin.ci && !value(rest, 'sha') }, io.git);
|
|
331
357
|
const environment = value(rest, 'environment');
|
|
358
|
+
const execution = executionOf(rest, env);
|
|
332
359
|
const warnings = [];
|
|
333
360
|
const reporter = createReporter({
|
|
334
361
|
producer,
|
|
@@ -336,6 +363,7 @@ export async function reportMain(argv, io = { out: text => process.stdout.write(
|
|
|
336
363
|
...(value(rest, 'title') ? { title: value(rest, 'title') } : {}),
|
|
337
364
|
sha, branch,
|
|
338
365
|
runUrl: value(rest, 'run-url') ?? origin.runUrl,
|
|
366
|
+
...(execution ? { run: execution } : {}),
|
|
339
367
|
...(freshness ? { freshness } : {}),
|
|
340
368
|
...(environment ? { environment } : {}),
|
|
341
369
|
parameters: Object.fromEntries(values(rest, 'param').map(pair => keyValue(pair, '--param')))
|
|
@@ -13,7 +13,7 @@ import { type PublishChecksInput } from '../../schema/src/layer.js';
|
|
|
13
13
|
import type { ObjectStore } from './object-store.js';
|
|
14
14
|
type Check = PublishChecksInput['checks'][number];
|
|
15
15
|
/** What every layer of one stream shares: who is reporting, from where. Whatever `publishChecks` takes, except the checks and the time. */
|
|
16
|
-
export type ReporterIdentity = Pick<PublishChecksInput, 'producer' | 'kind' | 'title' | 'sha' | 'branch' | 'runUrl' | 'environment' | 'parameters' | 'freshness' | 'specs' | 'tags' | 'labels' | 'links' | 'attachments' | 'annotations' | 'extensions'>;
|
|
16
|
+
export type ReporterIdentity = Pick<PublishChecksInput, 'producer' | 'kind' | 'title' | 'sha' | 'branch' | 'runUrl' | 'run' | 'environment' | 'parameters' | 'freshness' | 'specs' | 'tags' | 'labels' | 'links' | 'attachments' | 'annotations' | 'extensions'>;
|
|
17
17
|
/** How a reporter sends: the `store` (a folder, S3, or `httpStore` for a server), how often (`flushMs`, `batchSize`), how hard it tries (`attempts`, `backoffMs`), what it tells (`onError`, `onPublished`), and the clock and sleep for tests. */
|
|
18
18
|
export type ReporterOptions = {
|
|
19
19
|
/** Where layers go: a directory, S3, or `httpStore` for an Atlas server. */
|