@drawcall/feedback 0.0.0-stage → 0.1.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/README.md +47 -2
- package/dist/api.d.ts +328 -0
- package/dist/api.js +202 -0
- package/dist/browser/device.d.ts +13 -0
- package/dist/browser/device.js +64 -0
- package/dist/browser/ending.d.ts +8 -0
- package/dist/browser/ending.js +241 -0
- package/dist/browser/frame.d.ts +27 -0
- package/dist/browser/frame.js +165 -0
- package/dist/browser/input.d.ts +45 -0
- package/dist/browser/input.js +173 -0
- package/dist/browser/intro.d.ts +13 -0
- package/dist/browser/intro.js +172 -0
- package/dist/browser/mic.d.ts +20 -0
- package/dist/browser/mic.js +98 -0
- package/dist/browser/pictures/LICENSE +24 -0
- package/dist/browser/pictures/controller.webp +0 -0
- package/dist/browser/pictures/hand.webp +0 -0
- package/dist/browser/run.d.ts +32 -0
- package/dist/browser/run.js +78 -0
- package/dist/browser/server.d.ts +22 -0
- package/dist/browser/server.js +147 -0
- package/dist/browser/session.d.ts +31 -0
- package/dist/browser/session.js +101 -0
- package/dist/browser/sounds/end.m4a +0 -0
- package/dist/browser/sounds/failed.m4a +0 -0
- package/dist/browser/sounds/hold-controller.m4a +0 -0
- package/dist/browser/sounds/hold-hand.m4a +0 -0
- package/dist/browser/sounds/nomic.m4a +0 -0
- package/dist/browser/sounds/sending.m4a +0 -0
- package/dist/browser/sounds/show-controller.m4a +0 -0
- package/dist/browser/sounds/show-hand.m4a +0 -0
- package/dist/browser/sounds/show.m4a +0 -0
- package/dist/browser/sounds/speak-controller.m4a +0 -0
- package/dist/browser/sounds/speak-hand.m4a +0 -0
- package/dist/browser/sounds/thanks.m4a +0 -0
- package/dist/browser/sounds/unheard.m4a +0 -0
- package/dist/browser/sounds/walkthrough/a-welcome.m4a +0 -0
- package/dist/browser/sounds/walkthrough/b-controller.m4a +0 -0
- package/dist/browser/sounds/walkthrough/b-hand.m4a +0 -0
- package/dist/browser/sounds/walkthrough/b-key.m4a +0 -0
- package/dist/browser/sounds/walkthrough/b-touch.m4a +0 -0
- package/dist/browser/sounds/walkthrough/b-type-key.m4a +0 -0
- package/dist/browser/sounds/walkthrough/b-type-touch.m4a +0 -0
- package/dist/browser/sounds/walkthrough/c-key.m4a +0 -0
- package/dist/browser/sounds/walkthrough/c-quest.m4a +0 -0
- package/dist/browser/sounds/walkthrough/c-touch.m4a +0 -0
- package/dist/browser/sounds/walkthrough/d-key.m4a +0 -0
- package/dist/browser/sounds/walkthrough/d-quest.m4a +0 -0
- package/dist/browser/sounds/walkthrough/d-touch.m4a +0 -0
- package/dist/browser/sounds.d.ts +140 -0
- package/dist/browser/sounds.js +118 -0
- package/dist/browser/style.d.ts +10 -0
- package/dist/browser/style.js +214 -0
- package/dist/browser/turn.d.ts +23 -0
- package/dist/browser/turn.js +274 -0
- package/dist/browser/ui.d.ts +88 -0
- package/dist/browser/ui.js +291 -0
- package/dist/browser/wait.d.ts +10 -0
- package/dist/browser/wait.js +32 -0
- package/dist/browser/walkthrough.d.ts +12 -0
- package/dist/browser/walkthrough.js +137 -0
- package/dist/browser/xr.d.ts +10 -0
- package/dist/browser/xr.js +99 -0
- package/dist/cli/bin.d.ts +2 -0
- package/dist/cli/bin.js +12 -0
- package/dist/cli/commands.d.ts +19 -0
- package/dist/cli/commands.js +153 -0
- package/dist/cli/files.d.ts +21 -0
- package/dist/cli/files.js +62 -0
- package/dist/cli/index.d.ts +14 -0
- package/dist/cli/index.js +94 -0
- package/dist/index.d.ts +109 -0
- package/dist/index.js +75 -0
- package/dist/notes.d.ts +12 -0
- package/dist/notes.js +23 -0
- package/dist/service.d.ts +53 -0
- package/dist/service.js +55 -0
- package/dist/tester.d.ts +39 -0
- package/dist/tester.js +84 -0
- package/package.json +54 -4
- package/skills/feedback/SKILL.md +117 -0
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { parseArgs } from "node:util";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { blob, packageVersion, releaseNotes, save, walk } from "./files.js";
|
|
6
|
+
/** A mistake in the command line, found before any request. */
|
|
7
|
+
export class UsageError extends Error {
|
|
8
|
+
}
|
|
9
|
+
function parse(args, options) {
|
|
10
|
+
try {
|
|
11
|
+
return parseArgs({ args, options, allowPositionals: true, allowNegative: true, strict: true });
|
|
12
|
+
}
|
|
13
|
+
catch (error) {
|
|
14
|
+
throw new UsageError(error instanceof Error ? error.message : String(error));
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
const STRING = { type: "string" };
|
|
18
|
+
const FLAG = { type: "boolean" };
|
|
19
|
+
function command(spec, run) {
|
|
20
|
+
const scoped = spec.scoped ?? true;
|
|
21
|
+
return {
|
|
22
|
+
usage: spec.usage, about: spec.about, scoped,
|
|
23
|
+
async run(args, api) {
|
|
24
|
+
const { values, positionals } = parse(args, { ...spec.flags, project: STRING });
|
|
25
|
+
const key = "project" in values && typeof values.project === "string" ? values.project : undefined;
|
|
26
|
+
if (!scoped && key !== undefined)
|
|
27
|
+
throw new UsageError("Unknown option '--project'");
|
|
28
|
+
// A usage that doesn't start with an argument, such as `<n>`, takes none.
|
|
29
|
+
if (!spec.usage.startsWith("<") && positionals.length)
|
|
30
|
+
throw new UsageError(`Unexpected argument "${positionals[0]}"`);
|
|
31
|
+
return run({ ...api, values, positionals, project: () => api.project(key) });
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
function one(positionals) {
|
|
36
|
+
const [first, ...rest] = positionals;
|
|
37
|
+
if (first === undefined || rest.length)
|
|
38
|
+
throw new UsageError(`Expected one argument, got ${positionals.length}`);
|
|
39
|
+
return first;
|
|
40
|
+
}
|
|
41
|
+
/** An item number, written `42` or `#42`. */
|
|
42
|
+
function number(value) {
|
|
43
|
+
const match = /^#?([1-9]\d*)$/.exec(value);
|
|
44
|
+
if (!match?.[1])
|
|
45
|
+
throw new UsageError(`"${value}" is not an item number`);
|
|
46
|
+
return Number(match[1]);
|
|
47
|
+
}
|
|
48
|
+
function numbers(positionals) {
|
|
49
|
+
if (!positionals.length)
|
|
50
|
+
throw new UsageError("Give item numbers");
|
|
51
|
+
return positionals.map(number);
|
|
52
|
+
}
|
|
53
|
+
/** A relative `/`-separated path with each part encoded for a URL. */
|
|
54
|
+
const encode = (path) => path.split("/").map(encodeURIComponent).join("/");
|
|
55
|
+
export const commands = {
|
|
56
|
+
list: command({
|
|
57
|
+
usage: "[--status open|resolved|all] [--tester <name>] [--version <v>]",
|
|
58
|
+
about: "Sessions, newest first, each with its items of --status (default open) and current reviewer trust",
|
|
59
|
+
flags: { status: STRING, tester: STRING, version: STRING },
|
|
60
|
+
}, async ({ values, project, json }) => {
|
|
61
|
+
const filter = Object.entries({ status: values.status ?? "open", tester: values.tester, version: values.version });
|
|
62
|
+
const query = new URLSearchParams(filter.filter((entry) => entry[1] !== undefined));
|
|
63
|
+
return json("GET", `${await project()}/sessions?${query}`);
|
|
64
|
+
}),
|
|
65
|
+
show: command({ usage: "<n> [--out <dir>]", about: "One item with current reviewer trust; --out saves its screenshot and audio there, adding their paths as `saved`", flags: { out: STRING } }, async ({ values, positionals, project, call, json }) => {
|
|
66
|
+
const n = number(one(positionals));
|
|
67
|
+
const path = `${await project()}/items/${n}`;
|
|
68
|
+
if (values.out === undefined)
|
|
69
|
+
return json("GET", path);
|
|
70
|
+
const item = z.looseObject({ n: z.number(), screenshot: z.boolean(), audio: z.string().nullable() }).parse(await json("GET", path));
|
|
71
|
+
const download = async (kind) => new Uint8Array(await (await call("GET", `${path}/${kind}`)).arrayBuffer());
|
|
72
|
+
return { ...item, saved: await save(item, values.out, download) };
|
|
73
|
+
}),
|
|
74
|
+
resolve: command({
|
|
75
|
+
usage: "<n...> [--reason fixed|duplicate|cannot-reproduce|wont-fix] [--of <n>] [--comment <text>]",
|
|
76
|
+
about: "Resolves items, as fixed unless --reason says why; --of <n> marks them duplicates of item n",
|
|
77
|
+
flags: { reason: STRING, of: STRING, comment: STRING },
|
|
78
|
+
}, async ({ values, positionals, project, json }) => {
|
|
79
|
+
const of = values.of === undefined ? undefined : number(values.of);
|
|
80
|
+
const body = { n: numbers(positionals), resolution: values.reason ?? (of === undefined ? "fixed" : "duplicate"), of, comment: values.comment };
|
|
81
|
+
return json("POST", `${await project()}/items/resolve`, body);
|
|
82
|
+
}),
|
|
83
|
+
reopen: command({ usage: "<n...>", about: "Opens resolved items again", flags: {} }, async ({ positionals, project, json }) => {
|
|
84
|
+
const n = numbers(positionals);
|
|
85
|
+
return json("POST", `${await project()}/items/reopen`, { n });
|
|
86
|
+
}),
|
|
87
|
+
publish: command({
|
|
88
|
+
usage: "<dir> [--version <v>] [--notes <file.md>]",
|
|
89
|
+
about: "Puts the build in <dir> live for testers as --version, default package.json's; --notes sends release notes, with the images and videos they point to",
|
|
90
|
+
flags: { version: STRING, notes: STRING },
|
|
91
|
+
}, async ({ values, positionals, project, call, json }) => {
|
|
92
|
+
const dir = one(positionals);
|
|
93
|
+
const version = values.version ?? await packageVersion();
|
|
94
|
+
if (!version)
|
|
95
|
+
throw new UsageError("Give --version, or run this where package.json has a version");
|
|
96
|
+
const paths = await walk(dir);
|
|
97
|
+
if (!paths.includes("index.html"))
|
|
98
|
+
throw new Error(`${dir} has no index.html at its top; build the app first`);
|
|
99
|
+
// Read before anything is uploaded, so a missing image fails the publish at once.
|
|
100
|
+
const notes = values.notes === undefined ? undefined : await releaseNotes(values.notes);
|
|
101
|
+
const releases = `${await project()}/releases`;
|
|
102
|
+
const release = `${releases}/${encodeURIComponent(version)}`;
|
|
103
|
+
await call("POST", releases, { version });
|
|
104
|
+
for (let i = 0; i < paths.length; i += 6) {
|
|
105
|
+
await Promise.all(paths.slice(i, i + 6).map(async (path) => call("PUT", `${release}/files/${encode(path)}`, await blob(join(dir, path)))));
|
|
106
|
+
}
|
|
107
|
+
for (const [path, file] of notes?.media ?? [])
|
|
108
|
+
await call("PUT", `${release}/media/${encode(path)}`, file);
|
|
109
|
+
return json("POST", `${release}/publish`, { notes: notes?.markdown });
|
|
110
|
+
}),
|
|
111
|
+
"reviewer list": command({ usage: "", about: "The people emailed about new versions, with their trust levels", flags: {} }, async ({ project, json }) => json("GET", `${await project()}/reviewers`)),
|
|
112
|
+
"reviewer add": command({
|
|
113
|
+
usage: "<email> [--name <name>] [--trust low|medium|high] [--every <days>] [--on major|minor|patch]",
|
|
114
|
+
about: "Adds a reviewer, or changes only the supplied settings of the one with that email; --trust: confidence in their feedback (default low); --every: days at least between emails (default 7); --on: smallest version change emailed (default major)",
|
|
115
|
+
flags: { name: STRING, trust: STRING, every: STRING, on: STRING },
|
|
116
|
+
}, async ({ values, positionals, project, json }) => {
|
|
117
|
+
const body = { email: one(positionals), name: values.name, trust: values.trust, every: values.every === undefined ? undefined : Number(values.every), on: values.on };
|
|
118
|
+
return json("POST", `${await project()}/reviewers`, body);
|
|
119
|
+
}),
|
|
120
|
+
"reviewer remove": command({ usage: "<email>", about: "Stops emailing a reviewer", flags: {} }, async ({ positionals, project, call }) => {
|
|
121
|
+
const email = one(positionals);
|
|
122
|
+
await call("DELETE", `${await project()}/reviewers/${encodeURIComponent(email)}`);
|
|
123
|
+
return { removed: email };
|
|
124
|
+
}),
|
|
125
|
+
"project create": command({ usage: "<name>", about: "Creates a project, with a `snippet` to start the integration from", flags: {}, scoped: false }, async ({ positionals, json }) => {
|
|
126
|
+
const project = z.looseObject({ key: z.string() }).parse(await json("POST", "projects", { name: one(positionals) }));
|
|
127
|
+
return { ...project, snippet: snippet(project.key) };
|
|
128
|
+
}),
|
|
129
|
+
"project list": command({ usage: "", about: "Your projects", flags: {}, scoped: false }, async ({ json }) => json("GET", "projects")),
|
|
130
|
+
"project set": command({
|
|
131
|
+
usage: "[--notify|--no-notify] [--signup|--no-signup]",
|
|
132
|
+
about: "--notify: emails you about new feedback; --signup: asks testers for their name and email",
|
|
133
|
+
flags: { notify: FLAG, signup: FLAG },
|
|
134
|
+
}, async ({ values, project, json }) => json("PATCH", await project(), { notify: values.notify, signup: values.signup })),
|
|
135
|
+
skill: command({ usage: "", about: "Prints the agent skill as text, which explains all of this", flags: {}, scoped: false }, async () => readFile(new URL("../../skills/feedback/SKILL.md", import.meta.url), "utf8")),
|
|
136
|
+
};
|
|
137
|
+
function snippet(key) {
|
|
138
|
+
return `import { feedback } from "@drawcall/feedback";
|
|
139
|
+
|
|
140
|
+
let wasPaused = false;
|
|
141
|
+
const fb = feedback({
|
|
142
|
+
project: "${key}",
|
|
143
|
+
renderer,
|
|
144
|
+
minutes: 10, // about how long the test takes, shown to testers first
|
|
145
|
+
task: "Try the new area and say what feels off.",
|
|
146
|
+
pause: () => {
|
|
147
|
+
wasPaused = app.paused;
|
|
148
|
+
app.paused = true; // simulations, animations, timers, audio; never rendering, camera or user movement
|
|
149
|
+
return { scene: app.scene.id }; // a few ids someone fixing a report would ask for first
|
|
150
|
+
},
|
|
151
|
+
resume: () => { app.paused = wasPaused; },
|
|
152
|
+
});`;
|
|
153
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** The local files the CLI reads and writes. */
|
|
2
|
+
/** Saves an item's screenshot and audio, fetched by `download`, to `directory` and returns their paths. */
|
|
3
|
+
export declare function save(item: {
|
|
4
|
+
n: number;
|
|
5
|
+
screenshot: boolean;
|
|
6
|
+
audio: string | null;
|
|
7
|
+
}, directory: string, download: (kind: "screenshot" | "audio") => Promise<Uint8Array>): Promise<{
|
|
8
|
+
screenshot: string | null;
|
|
9
|
+
audio: string | null;
|
|
10
|
+
}>;
|
|
11
|
+
/** Every file under `directory` as a sorted `/`-separated relative path, leaving out dotfiles such as .DS_Store. */
|
|
12
|
+
export declare function walk(directory: string): Promise<string[]>;
|
|
13
|
+
/** A file as a blob with the type it is served with. */
|
|
14
|
+
export declare function blob(path: string): Promise<Blob>;
|
|
15
|
+
/** The `version` of the package.json in the working directory, or undefined without one. */
|
|
16
|
+
export declare function packageVersion(): Promise<string | undefined>;
|
|
17
|
+
/** The release notes at `file`, with the images and videos they point to. */
|
|
18
|
+
export declare function releaseNotes(file: string): Promise<{
|
|
19
|
+
markdown: string;
|
|
20
|
+
media: Array<[string, Blob]>;
|
|
21
|
+
}>;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname, join, relative, sep } from "node:path";
|
|
3
|
+
import mime from "mime";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { attachments } from "../notes.js";
|
|
6
|
+
/** The local files the CLI reads and writes. */
|
|
7
|
+
/** Saves an item's screenshot and audio, fetched by `download`, to `directory` and returns their paths. */
|
|
8
|
+
export async function save(item, directory, download) {
|
|
9
|
+
await mkdir(directory, { recursive: true });
|
|
10
|
+
const write = async (kind, name) => {
|
|
11
|
+
const path = join(directory, name);
|
|
12
|
+
await writeFile(path, await download(kind));
|
|
13
|
+
return path;
|
|
14
|
+
};
|
|
15
|
+
return {
|
|
16
|
+
screenshot: item.screenshot ? await write("screenshot", `${item.n}.jpg`) : null,
|
|
17
|
+
audio: item.audio ? await write("audio", `${item.n}.${extension(item.audio)}`) : null,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
/** Such as `webm` for `audio/webm;codecs=opus`. */
|
|
21
|
+
function extension(type) {
|
|
22
|
+
const match = /^audio\/([\w.+-]+)/.exec(type);
|
|
23
|
+
if (!match?.[1])
|
|
24
|
+
throw new Error(`Unexpected audio type ${type}`);
|
|
25
|
+
return match[1];
|
|
26
|
+
}
|
|
27
|
+
/** Every file under `directory` as a sorted `/`-separated relative path, leaving out dotfiles such as .DS_Store. */
|
|
28
|
+
export async function walk(directory) {
|
|
29
|
+
const entries = await readdir(directory, { recursive: true, withFileTypes: true });
|
|
30
|
+
return entries
|
|
31
|
+
.filter(entry => entry.isFile())
|
|
32
|
+
.map(entry => relative(directory, join(entry.parentPath, entry.name)).split(sep).join("/"))
|
|
33
|
+
.filter(path => !path.split("/").some(part => part.startsWith(".")))
|
|
34
|
+
.sort();
|
|
35
|
+
}
|
|
36
|
+
/** A file as a blob with the type it is served with. */
|
|
37
|
+
export async function blob(path) {
|
|
38
|
+
return new Blob([await readFile(path)], { type: mime.getType(path) ?? "application/octet-stream" });
|
|
39
|
+
}
|
|
40
|
+
/** The `version` of the package.json in the working directory, or undefined without one. */
|
|
41
|
+
export async function packageVersion() {
|
|
42
|
+
let file;
|
|
43
|
+
try {
|
|
44
|
+
file = await readFile("package.json", "utf8");
|
|
45
|
+
}
|
|
46
|
+
catch (error) {
|
|
47
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT")
|
|
48
|
+
return undefined;
|
|
49
|
+
throw error;
|
|
50
|
+
}
|
|
51
|
+
return z.object({ version: z.string().optional() }).parse(JSON.parse(file)).version;
|
|
52
|
+
}
|
|
53
|
+
/** The release notes at `file`, with the images and videos they point to. */
|
|
54
|
+
export async function releaseNotes(file) {
|
|
55
|
+
const markdown = await readFile(file, "utf8");
|
|
56
|
+
const media = await Promise.all(attachments(markdown).map(async (path) => {
|
|
57
|
+
if (!/^(?:image|video)\//.test(mime.getType(path) ?? ""))
|
|
58
|
+
throw new Error(`The notes point to ${path}; only images and videos can go with them`);
|
|
59
|
+
return [path, await blob(join(dirname(file), path))];
|
|
60
|
+
}));
|
|
61
|
+
return { markdown, media };
|
|
62
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The CLI's users are agents, so every command prints JSON: the response on stdout, or `{ error }` on stderr with exit
|
|
3
|
+
* code 1, or `{ error, usage }` with exit code 2 for a mistake in the command line, found before any request.
|
|
4
|
+
*/
|
|
5
|
+
export interface CliHost {
|
|
6
|
+
fetch: typeof globalThis.fetch;
|
|
7
|
+
token(): Promise<string | undefined>;
|
|
8
|
+
stdout(text: string): void;
|
|
9
|
+
stderr(text: string): void;
|
|
10
|
+
baseUrl?: string;
|
|
11
|
+
/** How the user runs this CLI, for the usage in help and errors. */
|
|
12
|
+
invocation?: string;
|
|
13
|
+
}
|
|
14
|
+
export declare function run(argv: string[], host: CliHost): Promise<number>;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { CONTROL, ORIGIN } from "../service.js";
|
|
3
|
+
import { commands, UsageError } from "./commands.js";
|
|
4
|
+
const SIGN_IN = "Not signed in, or the sign-in expired: run `npx drawcall auth login`, or set DRAWCALL_AUTH_TOKEN";
|
|
5
|
+
const ABOUT = "Every command prints JSON. Errors print {\"error\"} on stderr and exit 2 for misuse, 1 otherwise. "
|
|
6
|
+
+ "--project <key> picks the project when you own several.";
|
|
7
|
+
export async function run(argv, host) {
|
|
8
|
+
const invocation = host.invocation ?? "npx @drawcall/feedback";
|
|
9
|
+
const usage = (name) => {
|
|
10
|
+
const command = commands[name];
|
|
11
|
+
return [invocation, name, command?.usage, command?.scoped && "[--project <key>]"].filter(Boolean).join(" ");
|
|
12
|
+
};
|
|
13
|
+
if (argv[0] === "help" || argv.includes("--help") || argv.includes("-h")) {
|
|
14
|
+
host.stdout(stringify(help(argv[0] === "help" ? argv.slice(1) : argv, usage)));
|
|
15
|
+
return 0;
|
|
16
|
+
}
|
|
17
|
+
const name = [argv.slice(0, 2).join(" "), argv[0] ?? ""].find(name => Object.hasOwn(commands, name));
|
|
18
|
+
const command = name === undefined ? undefined : commands[name];
|
|
19
|
+
if (name === undefined || !command) {
|
|
20
|
+
const group = Object.keys(commands).filter(name => name.startsWith(`${argv[0]} `));
|
|
21
|
+
const error = group.length ? `Give one of ${group.join(", ")}` : argv.length ? `Unknown command "${argv[0]}"` : "Give a command";
|
|
22
|
+
host.stderr(stringify({ error: `${error}; see \`${invocation} --help\``, usage: `${invocation} <command>` }));
|
|
23
|
+
return 2;
|
|
24
|
+
}
|
|
25
|
+
try {
|
|
26
|
+
const result = await command.run(argv.slice(name.split(" ").length), api(host, invocation));
|
|
27
|
+
// Only `skill` answers with text, the skill itself.
|
|
28
|
+
host.stdout(typeof result === "string" ? result : stringify(result));
|
|
29
|
+
return 0;
|
|
30
|
+
}
|
|
31
|
+
catch (error) {
|
|
32
|
+
if (error instanceof UsageError) {
|
|
33
|
+
host.stderr(stringify({ error: error.message, usage: usage(name) }));
|
|
34
|
+
return 2;
|
|
35
|
+
}
|
|
36
|
+
host.stderr(stringify({ error: error instanceof Error ? error.message : String(error) }));
|
|
37
|
+
return 1;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/** The commands the words in `argv` name, such as all `reviewer` ones for `reviewer --help`; all of them without any. */
|
|
41
|
+
function help(argv, usage) {
|
|
42
|
+
const under = (words) => Object.keys(commands).filter(name => `${name} `.startsWith(words.length ? `${words.join(" ")} ` : ""));
|
|
43
|
+
const words = argv.filter(arg => !arg.startsWith("-")).slice(0, 2);
|
|
44
|
+
// Drops words that name no command, such as the 42 of `show 42 --help`.
|
|
45
|
+
while (words.length && !under(words).length)
|
|
46
|
+
words.pop();
|
|
47
|
+
return { about: ABOUT, commands: Object.fromEntries(under(words).map(name => [name, { usage: usage(name), about: commands[name]?.about }])) };
|
|
48
|
+
}
|
|
49
|
+
/** The signed-in routes of feedback.drawcall.ai. */
|
|
50
|
+
function api(host, invocation) {
|
|
51
|
+
const base = new URL("/api/v1/", host.baseUrl ?? ORIGIN);
|
|
52
|
+
let token;
|
|
53
|
+
/** Sends a blob as its own type, anything else as JSON. */
|
|
54
|
+
async function call(method, path, body) {
|
|
55
|
+
const bearer = await (token ??= host.token());
|
|
56
|
+
if (!bearer)
|
|
57
|
+
throw new Error(SIGN_IN);
|
|
58
|
+
const headers = { authorization: `Bearer ${bearer}` };
|
|
59
|
+
if (body !== undefined && !(body instanceof Blob))
|
|
60
|
+
headers["content-type"] = "application/json";
|
|
61
|
+
const payload = body instanceof Blob || body === undefined ? body : JSON.stringify(body);
|
|
62
|
+
const response = await host.fetch(new URL(path, base), { method, headers, body: payload });
|
|
63
|
+
if (response.ok)
|
|
64
|
+
return response;
|
|
65
|
+
if (response.status === 401)
|
|
66
|
+
throw new Error(SIGN_IN);
|
|
67
|
+
const error = z.object({ message: z.string() }).safeParse(await response.json().catch(() => undefined));
|
|
68
|
+
throw new Error(`Feedback request failed (${response.status})${error.success ? `: ${error.data.message}` : ""}`);
|
|
69
|
+
}
|
|
70
|
+
const json = async (method, path, body) => (await call(method, path, body)).json();
|
|
71
|
+
async function project(key) {
|
|
72
|
+
const projects = z.array(z.object({ key: z.string(), name: z.string() })).parse(await json("GET", "projects"));
|
|
73
|
+
if (key) {
|
|
74
|
+
if (!projects.some(project => project.key === key))
|
|
75
|
+
throw new Error(`You have no project ${key}; \`${invocation} project list\` shows yours`);
|
|
76
|
+
return `projects/${key}`;
|
|
77
|
+
}
|
|
78
|
+
const [only, ...others] = projects;
|
|
79
|
+
if (!only)
|
|
80
|
+
throw new Error(`You have no projects yet; create one with \`${invocation} project create <name>\``);
|
|
81
|
+
if (others.length)
|
|
82
|
+
throw new Error(`You own ${projects.length} projects, so pass --project with one of ${projects.map(one => `${one.key} (${one.name})`).join(", ")}`);
|
|
83
|
+
return `projects/${only.key}`;
|
|
84
|
+
}
|
|
85
|
+
return { call, json, project };
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Pretty JSON with every `CONTROL` character in its strings escaped: `JSON.stringify` escapes only C0 ones, and a
|
|
89
|
+
* terminal acts on raw C1 ones just the same, such as U+009B, which starts a control sequence. Its own line breaks stay.
|
|
90
|
+
*/
|
|
91
|
+
function stringify(data) {
|
|
92
|
+
return JSON.stringify(data, null, 2)
|
|
93
|
+
.replace(new RegExp(CONTROL, "g"), char => (char === "\n" ? char : `\\u${char.charCodeAt(0).toString(16).padStart(4, "0")}`));
|
|
94
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import type { WebGLRenderer } from "three";
|
|
2
|
+
import type { WebGPURenderer } from "three/webgpu";
|
|
3
|
+
export type Renderer = WebGLRenderer | WebGPURenderer;
|
|
4
|
+
/**
|
|
5
|
+
* The look of everything feedback shows: the intro and the captions and button over the app. Colors take any CSS
|
|
6
|
+
* color; the muted text and quiet fills are mixed from `text`, so a light theme works as well as a dark one.
|
|
7
|
+
*/
|
|
8
|
+
export interface Theme {
|
|
9
|
+
/** A logo image's URL, shown at the top of the intro. */
|
|
10
|
+
logo?: string;
|
|
11
|
+
/** A CSS font-family. A web font must already be loaded by the page. */
|
|
12
|
+
font?: string;
|
|
13
|
+
/** The intro's page. */
|
|
14
|
+
background?: string;
|
|
15
|
+
/** The plates behind captions and the corner button, slightly translucent over the app. */
|
|
16
|
+
surface?: string;
|
|
17
|
+
text?: string;
|
|
18
|
+
/** Buttons and highlights, such as Start and Send. */
|
|
19
|
+
accent?: string;
|
|
20
|
+
/** Text on `accent`. */
|
|
21
|
+
onAccent?: string;
|
|
22
|
+
/** Corner radius in pixels. */
|
|
23
|
+
radius?: number;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* A device a test can be for: a preset the intro already tells apart (Meta Quest's browser, a touch phone or
|
|
27
|
+
* tablet, or anything else as a computer), or the app's own check with the name testers read, such as
|
|
28
|
+
* `{ name: "Apple Vision Pro", matches: () => /VisionOS/.test(navigator.userAgent) }`.
|
|
29
|
+
*/
|
|
30
|
+
export type Target = "desktop" | "phone" | "quest" | {
|
|
31
|
+
name: string;
|
|
32
|
+
matches: () => boolean | Promise<boolean>;
|
|
33
|
+
};
|
|
34
|
+
/**
|
|
35
|
+
* A question on the closing screen, after the app calls `finish()`. Every one is optional for the tester. `id` names
|
|
36
|
+
* it in the answers, so keep it stable when the wording changes between versions. A `scale` is a slider from `min` to
|
|
37
|
+
* `max`, with words for its two ends; a `choice` takes one of its `options`, or several when `multiple`.
|
|
38
|
+
*/
|
|
39
|
+
export type Question = {
|
|
40
|
+
type: "scale";
|
|
41
|
+
id: string;
|
|
42
|
+
ask: string;
|
|
43
|
+
min: number;
|
|
44
|
+
max: number;
|
|
45
|
+
step?: number;
|
|
46
|
+
labels?: [string, string];
|
|
47
|
+
} | {
|
|
48
|
+
type: "choice";
|
|
49
|
+
id: string;
|
|
50
|
+
ask: string;
|
|
51
|
+
options: string[];
|
|
52
|
+
multiple?: boolean;
|
|
53
|
+
};
|
|
54
|
+
export interface FeedbackOptions {
|
|
55
|
+
/** The public project key, `fbk_pub_…`. */
|
|
56
|
+
project: string;
|
|
57
|
+
/**
|
|
58
|
+
* The renderer whose frames the tester can show. The screenshot is taken from the next frame it renders, so an app
|
|
59
|
+
* that renders on demand (such as React Three Fiber's `frameloop="demand"`) must keep rendering while feedback is on.
|
|
60
|
+
*/
|
|
61
|
+
renderer: Renderer;
|
|
62
|
+
/**
|
|
63
|
+
* The build being tested, such as `1.4.0`, recorded with each session: up to 32 letters, digits and `. _ + -`. A page
|
|
64
|
+
* `publish` serves always records the published version instead.
|
|
65
|
+
*/
|
|
66
|
+
version?: string;
|
|
67
|
+
/** About how long the test takes, in whole minutes up to 120, shown to testers before they start. */
|
|
68
|
+
minutes?: number;
|
|
69
|
+
/** What testers are asked to do, shown before they start, such as "Find the crater and say what feels off." */
|
|
70
|
+
task?: string;
|
|
71
|
+
theme?: Theme;
|
|
72
|
+
/**
|
|
73
|
+
* The devices the test is for, when only some are. On any other, the intro first says which ones, with the link
|
|
74
|
+
* to open there, and lets the tester go on anyway; the device is recorded with their session either way.
|
|
75
|
+
*/
|
|
76
|
+
devices?: Target[];
|
|
77
|
+
/**
|
|
78
|
+
* Asked on the closing screen, above the space for a last word; answers are stored with the tester's session. Up to 20,
|
|
79
|
+
* each with an id of up to 64 characters, wording of up to 200, and up to 20 options or labels of up to 200 each.
|
|
80
|
+
*/
|
|
81
|
+
questions?: Question[];
|
|
82
|
+
/**
|
|
83
|
+
* Runs when a feedback turn starts, never for the intro or at load. Freezes the simulation, never rendering or the
|
|
84
|
+
* user's movement, and returns a few plain-JSON facts. When it throws, the turn goes on without them.
|
|
85
|
+
*/
|
|
86
|
+
pause?: () => unknown;
|
|
87
|
+
/**
|
|
88
|
+
* Undoes `pause()`. Runs once the tester is done: after they show what they mean, or once their feedback is known
|
|
89
|
+
* to need no screenshot, at most about 12 s after they stop talking. Also runs at dispose. The upload may still be
|
|
90
|
+
* running; its outcome shows once it is done.
|
|
91
|
+
*/
|
|
92
|
+
resume?: () => void;
|
|
93
|
+
}
|
|
94
|
+
export interface Feedback {
|
|
95
|
+
/**
|
|
96
|
+
* Call where the experience ends. Records that the tester went through it, then, once any feedback in progress is
|
|
97
|
+
* done, shows the closing screen: the app's `questions` and an optional last word on the whole experience, written or
|
|
98
|
+
* spoken, then thanks with the offer to try again, since the finished app stays behind it. That screen is a page,
|
|
99
|
+
* so an XR session ends for it. Once per page load; it does nothing outside feedback mode.
|
|
100
|
+
*/
|
|
101
|
+
finish(): void;
|
|
102
|
+
/** Stops recording, resumes if paused, and unhooks the renderer. */
|
|
103
|
+
dispose(): void;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Turns on voice feedback when the URL has `?feedback`, or on a page `publish` serves. Otherwise it only
|
|
107
|
+
* reads the URL, the page's meta tags and web storage, and loads nothing else.
|
|
108
|
+
*/
|
|
109
|
+
export declare function feedback(options: FeedbackOptions): Feedback;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { readTester } from "./tester.js";
|
|
2
|
+
import { CONTROL, LIMITS, VERSION_FORMAT, VERSION_META } from "./service.js";
|
|
3
|
+
/**
|
|
4
|
+
* Turns on voice feedback when the URL has `?feedback`, or on a page `publish` serves. Otherwise it only
|
|
5
|
+
* reads the URL, the page's meta tags and web storage, and loads nothing else.
|
|
6
|
+
*/
|
|
7
|
+
export function feedback(options) {
|
|
8
|
+
// Checked on every load, so a mistake shows while developing rather than only at the end of a test.
|
|
9
|
+
check(options);
|
|
10
|
+
const published = typeof document === "undefined" ? null : (document.querySelector(`meta[name="${VERSION_META}"]`)?.content ?? null);
|
|
11
|
+
const tester = currentTester(published !== null);
|
|
12
|
+
if (!tester)
|
|
13
|
+
return { finish() { }, dispose() { } };
|
|
14
|
+
const disposal = new AbortController();
|
|
15
|
+
let finish = () => { };
|
|
16
|
+
const finished = new Promise(resolve => (finish = resolve));
|
|
17
|
+
void import("./browser/run.js").then(({ run }) => run({ options, tester, version: published ?? options.version ?? null, finished, signal: disposal.signal }));
|
|
18
|
+
return { finish, dispose: () => disposal.abort() };
|
|
19
|
+
}
|
|
20
|
+
/** Holds the options to what the service accepts, which would otherwise refuse the session or its answers. */
|
|
21
|
+
function check({ version, minutes, questions = [] }) {
|
|
22
|
+
if (version !== undefined && !VERSION_FORMAT.test(version)) {
|
|
23
|
+
throw new Error(`feedback: version takes up to ${LIMITS.version} letters, digits and . _ + -, starting with a letter or digit, not "${version}"`);
|
|
24
|
+
}
|
|
25
|
+
if (minutes !== undefined && !(Number.isInteger(minutes) && minutes >= 1 && minutes <= LIMITS.minutes)) {
|
|
26
|
+
throw new Error(`feedback: minutes takes a whole number from 1 to ${LIMITS.minutes}, not ${minutes}`);
|
|
27
|
+
}
|
|
28
|
+
if (questions.length > LIMITS.questions)
|
|
29
|
+
throw new Error(`feedback: ${questions.length} questions, over the ${LIMITS.questions} allowed`);
|
|
30
|
+
const ids = new Set();
|
|
31
|
+
for (const question of questions) {
|
|
32
|
+
const { id } = question;
|
|
33
|
+
text(id, LIMITS.id, "a question's id");
|
|
34
|
+
text(question.ask, LIMITS.ask, `question "${id}"`);
|
|
35
|
+
if (ids.has(id))
|
|
36
|
+
throw new Error(`feedback: two questions have the id "${id}"`);
|
|
37
|
+
ids.add(id);
|
|
38
|
+
if (question.type === "scale") {
|
|
39
|
+
const { min, max, step = 1, labels = [] } = question;
|
|
40
|
+
if (!(Number.isFinite(min) && Number.isFinite(max) && min < max))
|
|
41
|
+
throw new Error(`feedback: question "${id}" needs min below max`);
|
|
42
|
+
if (!(Number.isFinite(step) && step > 0))
|
|
43
|
+
throw new Error(`feedback: question "${id}" needs a step above 0`);
|
|
44
|
+
for (const label of labels)
|
|
45
|
+
text(label, LIMITS.option, `a label of question "${id}"`);
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
const { options } = question;
|
|
49
|
+
if (options.length < 2 || options.length > LIMITS.options) {
|
|
50
|
+
throw new Error(`feedback: question "${id}" needs from 2 to ${LIMITS.options} options, not ${options.length}`);
|
|
51
|
+
}
|
|
52
|
+
for (const option of options)
|
|
53
|
+
text(option, LIMITS.option, `an option of question "${id}"`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
/** Text the service stores: not empty, at most `max` characters, and plain. */
|
|
58
|
+
function text(value, max, what) {
|
|
59
|
+
if (!value || value.length > max)
|
|
60
|
+
throw new Error(`feedback: ${what} takes from 1 to ${max} characters, not ${value.length}`);
|
|
61
|
+
if (CONTROL.test(value))
|
|
62
|
+
throw new Error(`feedback: ${what} holds a control character`);
|
|
63
|
+
}
|
|
64
|
+
function currentTester(published) {
|
|
65
|
+
if (typeof location === "undefined")
|
|
66
|
+
return null;
|
|
67
|
+
try {
|
|
68
|
+
return readTester(location.search, { session: sessionStorage, local: localStorage }, published);
|
|
69
|
+
}
|
|
70
|
+
catch (error) {
|
|
71
|
+
// Sandboxed iframes throw on any storage access; an app must keep running there, just without feedback.
|
|
72
|
+
console.warn("feedback: web storage is blocked here, so feedback mode stays off", error);
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
}
|
package/dist/notes.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Release notes: Markdown that `publish --notes` sends with a version, and reviewers get in their email. Images and
|
|
3
|
+
* videos it points to by relative path, such as ``, are uploaded with it.
|
|
4
|
+
*/
|
|
5
|
+
/** Whether a target is a file next to the notes rather than a URL, a page anchor or a site-absolute path. */
|
|
6
|
+
export declare function isLocal(target: string): boolean;
|
|
7
|
+
/** A local target as the `/`-separated path it is uploaded at, such as `shots/door.gif` for `./shots/door.gif`. */
|
|
8
|
+
export declare function localPath(target: string): string;
|
|
9
|
+
/** The local files the notes point to, each once. */
|
|
10
|
+
export declare function attachments(markdown: string): string[];
|
|
11
|
+
/** The notes with every local target replaced by `url(path)`, for the plain-text email. */
|
|
12
|
+
export declare function absolute(markdown: string, url: (path: string) => string): string;
|
package/dist/notes.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Release notes: Markdown that `publish --notes` sends with a version, and reviewers get in their email. Images and
|
|
3
|
+
* videos it points to by relative path, such as ``, are uploaded with it.
|
|
4
|
+
*/
|
|
5
|
+
/** A Markdown link or image target, `](target)` or `](<target> "title")`. */
|
|
6
|
+
const TARGET = /\]\(\s*<?([^)\s>]+)>?(?:\s+(?:"[^"]*"|'[^']*'))?\s*\)/g;
|
|
7
|
+
/** Whether a target is a file next to the notes rather than a URL, a page anchor or a site-absolute path. */
|
|
8
|
+
export function isLocal(target) {
|
|
9
|
+
return !/^(?:[a-z][a-z0-9+.-]*:|\/|#)/i.test(target);
|
|
10
|
+
}
|
|
11
|
+
/** A local target as the `/`-separated path it is uploaded at, such as `shots/door.gif` for `./shots/door.gif`. */
|
|
12
|
+
export function localPath(target) {
|
|
13
|
+
return decodeURI(target).replace(/^(?:\.\/)+/, "");
|
|
14
|
+
}
|
|
15
|
+
/** The local files the notes point to, each once. */
|
|
16
|
+
export function attachments(markdown) {
|
|
17
|
+
const paths = [...markdown.matchAll(TARGET)].map(match => match[1] ?? "").filter(isLocal).map(localPath);
|
|
18
|
+
return [...new Set(paths)];
|
|
19
|
+
}
|
|
20
|
+
/** The notes with every local target replaced by `url(path)`, for the plain-text email. */
|
|
21
|
+
export function absolute(markdown, url) {
|
|
22
|
+
return markdown.replace(TARGET, (whole, target) => (isLocal(target) ? whole.replace(target, url(localPath(target))) : whole));
|
|
23
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where the service runs and what it accepts. Free of zod, so the browser SDK can import it without shipping a
|
|
3
|
+
* schema library; `api.ts` builds its schemas on these.
|
|
4
|
+
*/
|
|
5
|
+
export declare const ORIGIN = "https://feedback.drawcall.ai";
|
|
6
|
+
/**
|
|
7
|
+
* The `<meta>` the service adds to each page of a published build, holding its version. Its presence alone turns
|
|
8
|
+
* feedback mode on.
|
|
9
|
+
*/
|
|
10
|
+
export declare const VERSION_META = "drawcall-feedback-version";
|
|
11
|
+
export declare const LIMITS: {
|
|
12
|
+
/** Bytes of `JSON.stringify(state)`. */
|
|
13
|
+
readonly state: number;
|
|
14
|
+
readonly audio: number;
|
|
15
|
+
/** Seconds of recording. */
|
|
16
|
+
readonly seconds: 90;
|
|
17
|
+
/** Characters of typed feedback. */
|
|
18
|
+
readonly text: 2000;
|
|
19
|
+
readonly screenshot: number;
|
|
20
|
+
/** Characters of a version's release notes. */
|
|
21
|
+
readonly notes: 20000;
|
|
22
|
+
/** Bytes of one image or video the release notes point to. */
|
|
23
|
+
readonly media: number;
|
|
24
|
+
/** Characters of a version, such as `1.4.0`. */
|
|
25
|
+
readonly version: 32;
|
|
26
|
+
/** The game's `minutes` option, the playtest's length in whole minutes. */
|
|
27
|
+
readonly minutes: 120;
|
|
28
|
+
/** Closing questions a game asks, and so answers a session stores. */
|
|
29
|
+
readonly questions: 20;
|
|
30
|
+
/** Options of one choice question. */
|
|
31
|
+
readonly options: 20;
|
|
32
|
+
/** Characters of a closing question's id. */
|
|
33
|
+
readonly id: 64;
|
|
34
|
+
/** Characters of a closing question's wording. */
|
|
35
|
+
readonly ask: 200;
|
|
36
|
+
/** Characters of one option of a choice question, or one end's label of a scale. */
|
|
37
|
+
readonly option: 200;
|
|
38
|
+
/** Characters of a tester's name. */
|
|
39
|
+
readonly name: 64;
|
|
40
|
+
/** Characters of an email address. */
|
|
41
|
+
readonly contact: 254;
|
|
42
|
+
/** Characters of the device description. */
|
|
43
|
+
readonly device: 200;
|
|
44
|
+
};
|
|
45
|
+
/** A version: letters, digits and `. _ + -`, starting with a letter or digit; safe in a URL path and an HTML attribute as it is. */
|
|
46
|
+
export declare const VERSION_FORMAT: RegExp;
|
|
47
|
+
/**
|
|
48
|
+
* A character plain text never holds: a C0 or C1 control character, which could rewrite the owner's terminal, or a bidi
|
|
49
|
+
* control, which could make text read differently than it runs where it ends up in a shell command or an email.
|
|
50
|
+
*/
|
|
51
|
+
export declare const CONTROL: RegExp;
|
|
52
|
+
/** `text` without `CONTROL` characters, except those `keep` lists, such as "\t\n" for text typed in a field of its own. */
|
|
53
|
+
export declare function stripControl(text: string, keep?: string): string;
|