@ihyeon/flow-qa 0.1.3 → 0.2.1
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.ko.md +9 -3
- package/README.md +9 -3
- package/THIRD_PARTY_NOTICES.md +1 -1
- package/dist/src/cli.js +42 -8
- package/dist/src/compiler.js +11 -3
- package/dist/src/engine.js +62 -10
- package/dist/src/i18n.js +13 -0
- package/dist/src/mobile-runtime.js +66 -0
- package/dist/src/planner.js +19 -7
- package/dist/src/report.js +4 -1
- package/dist/src/schema.js +21 -1
- package/dist/src/targets.js +20 -0
- package/dist/src/ui/public/app.js +61 -17
- package/dist/src/ui/public/i18n.js +27 -3
- package/dist/src/ui/public/index.html +6 -2
- package/dist/src/ui/public/style.css +3 -1
- package/dist/src/ui/server.js +54 -15
- package/docs/architecture.md +7 -5
- package/docs/mobile.md +59 -0
- package/docs/releases.md +18 -0
- package/docs/ui.md +15 -7
- package/package.json +4 -2
package/dist/src/schema.js
CHANGED
|
@@ -12,6 +12,9 @@ export const locatorSchema = z.discriminatedUnion('by', [
|
|
|
12
12
|
]);
|
|
13
13
|
export const stepSchema = z.discriminatedUnion('type', [
|
|
14
14
|
z.object({ type: z.literal('open'), path: text.refine((v) => v.startsWith('/') && !v.startsWith('//') && !v.includes('\\'), 'Use an app-relative path starting with /.') }).strict(),
|
|
15
|
+
z.object({ type: z.literal('launchApp') }).strict(),
|
|
16
|
+
z.object({ type: z.literal('back') }).strict(),
|
|
17
|
+
z.object({ type: z.literal('swipe'), direction: z.enum(['up', 'down', 'left', 'right']) }).strict(),
|
|
15
18
|
z.object({ type: z.literal('act'), instruction: text }).strict(),
|
|
16
19
|
z.object({ type: z.literal('click'), locator: locatorSchema }).strict(),
|
|
17
20
|
z.object({ type: z.literal('fill'), locator: locatorSchema, value: z.string().max(8192) }).strict(),
|
|
@@ -30,6 +33,7 @@ const caseSchema = z.object({
|
|
|
30
33
|
}).strict();
|
|
31
34
|
export const suiteSchema = z.object({
|
|
32
35
|
schemaVersion: z.literal('flow-qa/suite-1'),
|
|
36
|
+
platform: z.enum(['web', 'mobile']).optional(),
|
|
33
37
|
id: idSchema,
|
|
34
38
|
title: text,
|
|
35
39
|
source: z.object({ file: text, sha256: z.string().regex(/^[a-f0-9]{64}$/) }).strict().optional(),
|
|
@@ -49,6 +53,13 @@ export const suiteSchema = z.object({
|
|
|
49
53
|
ctx.addIssue({ code: 'custom', path: ['cases', index, 'requirementIds'], message: 'Unknown requirement ID.' });
|
|
50
54
|
if (!test.questions.length && !test.steps.some((s) => s.type.startsWith('assert')))
|
|
51
55
|
ctx.addIssue({ code: 'custom', path: ['cases', index, 'steps'], message: 'A runnable case needs an explicit assertion.' });
|
|
56
|
+
test.steps.forEach((step, stepIndex) => {
|
|
57
|
+
const invalid = suite.platform === 'mobile'
|
|
58
|
+
? step.type === 'open' || step.type === 'assertUrl'
|
|
59
|
+
: step.type === 'launchApp' || step.type === 'back' || step.type === 'swipe';
|
|
60
|
+
if (invalid)
|
|
61
|
+
ctx.addIssue({ code: 'custom', path: ['cases', index, 'steps', stepIndex, 'type'], message: `The ${step.type} step is not available on ${suite.platform ?? 'web'} suites.` });
|
|
62
|
+
});
|
|
52
63
|
});
|
|
53
64
|
});
|
|
54
65
|
export const modelSchema = z.object({
|
|
@@ -57,10 +68,19 @@ export const modelSchema = z.object({
|
|
|
57
68
|
apiKeyEnv: z.string().regex(/^[A-Z][A-Z0-9_]*$/).optional(),
|
|
58
69
|
}).strict();
|
|
59
70
|
export const plannerSchema = modelSchema;
|
|
60
|
-
|
|
71
|
+
const webTargetSchema = z.object({
|
|
61
72
|
url: httpURL,
|
|
62
73
|
description: z.string().max(2000).default(''),
|
|
74
|
+
kind: z.literal('web').optional(),
|
|
63
75
|
}).strict();
|
|
76
|
+
const mobileTargetSchema = z.object({
|
|
77
|
+
kind: z.literal('mobile'),
|
|
78
|
+
platform: z.enum(['ios', 'android']),
|
|
79
|
+
bundleId: z.string().min(1).max(200).regex(/^[A-Za-z0-9][A-Za-z0-9._-]*$/, 'Use a native app ID without spaces or control characters.'),
|
|
80
|
+
device: z.string().trim().min(1).max(200).refine((value) => !/[\p{Cc}\p{Cf}]/u.test(value), 'Device names cannot contain control characters.'),
|
|
81
|
+
description: z.string().max(2000).default(''),
|
|
82
|
+
}).strict();
|
|
83
|
+
export const targetSchema = z.union([webTargetSchema, mobileTargetSchema]);
|
|
64
84
|
export const configSchema = z.object({
|
|
65
85
|
schemaVersion: z.literal('flow-qa/config-1'),
|
|
66
86
|
targets: z.record(idSchema, targetSchema),
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
export function targetKind(target) {
|
|
3
|
+
return target.kind === 'mobile' ? 'mobile' : 'web';
|
|
4
|
+
}
|
|
5
|
+
export function targetLabel(target) {
|
|
6
|
+
return target.kind === 'mobile'
|
|
7
|
+
? `${target.platform} · ${target.bundleId} · ${target.device}`
|
|
8
|
+
: target.url;
|
|
9
|
+
}
|
|
10
|
+
export function targetFingerprint(target) {
|
|
11
|
+
const identity = target.kind === 'mobile'
|
|
12
|
+
? { kind: 'mobile', platform: target.platform, bundleId: target.bundleId, device: target.device }
|
|
13
|
+
: { kind: 'web', url: target.url };
|
|
14
|
+
return createHash('sha256').update(JSON.stringify(identity)).digest('hex');
|
|
15
|
+
}
|
|
16
|
+
export function reportTarget(name, target) {
|
|
17
|
+
return target.kind === 'mobile'
|
|
18
|
+
? { name, kind: 'mobile', platform: target.platform, bundleId: target.bundleId, device: target.device }
|
|
19
|
+
: { name, url: target.url, ...(target.kind === 'web' ? { kind: 'web' } : {}) };
|
|
20
|
+
}
|
|
@@ -55,10 +55,16 @@
|
|
|
55
55
|
$('sidebar').classList.remove('nav-open'); $('nav-toggle').setAttribute('aria-expanded', 'false');
|
|
56
56
|
if (name === 'history') renderHistory();
|
|
57
57
|
}
|
|
58
|
+
function suitePlatform() { return app.draft?.platform || 'web'; }
|
|
59
|
+
function targetPlatform(target) { return target?.kind === 'mobile' ? 'mobile' : 'web'; }
|
|
60
|
+
function selectedTarget() { return app.displayTargets.find((target) => target.name === $('target-select').value); }
|
|
61
|
+
function targetLabel(target) { return target?.kind === 'mobile' ? `${target.platform === 'ios' ? 'iOS' : 'Android'} · ${target.bundleId} · ${target.device}` : target?.url || ''; }
|
|
62
|
+
function targetDisplay(target) { return `${target?.name || t('app.noTarget')} · ${targetLabel(target)}`; }
|
|
63
|
+
function compatibleTarget() { const target = selectedTarget(); return target && targetPlatform(target) === suitePlatform(); }
|
|
58
64
|
function readiness() {
|
|
59
65
|
const active = app.state?.active;
|
|
60
66
|
const ready = !!app.draft && !!app.script && !app.stale && !app.dirty;
|
|
61
|
-
$('run-btn').disabled = !app.connected || !ready || !app.savedFile || !app.savedHash || !$('review-checkbox').checked ||
|
|
67
|
+
$('run-btn').disabled = !app.connected || !ready || !app.savedFile || !app.savedHash || !$('review-checkbox').checked || !compatibleTarget() || !!active || app.busy;
|
|
62
68
|
$('save-btn').disabled = !ready || app.busy;
|
|
63
69
|
$('script-download').disabled = !ready;
|
|
64
70
|
for (const id of ['stop-btn', 'global-stop-btn']) {
|
|
@@ -69,9 +75,14 @@
|
|
|
69
75
|
$('plan-btn').disabled = !app.connected || !app.state?.models?.planner?.configured || app.state.models.planner.kind !== 'openai-compatible' || !app.state.models.planner.keyReady || !!active || app.busy;
|
|
70
76
|
$('compile-btn').disabled = app.stale || app.busy;
|
|
71
77
|
$('new-draft-btn').disabled = app.busy;
|
|
78
|
+
$('import-btn').disabled = app.busy;
|
|
79
|
+
$('import-json').disabled = app.busy;
|
|
80
|
+
$('import-markdown').disabled = app.busy;
|
|
72
81
|
$('flow-input').disabled = app.busy;
|
|
73
82
|
$('suite-editor').disabled = app.busy;
|
|
74
83
|
$('prompt-btn').disabled = app.busy;
|
|
84
|
+
$('scenario-platform').disabled = app.busy;
|
|
85
|
+
$('mobile-setup-hint').hidden = suitePlatform() !== 'mobile' && $('scenario-platform').value !== 'mobile';
|
|
75
86
|
$('run-readiness').textContent = t(!app.connected ? 'app.connectionRequired'
|
|
76
87
|
: active ? (active.status === 'stopping' ? 'app.stopping' : active.kind === 'plan' ? 'app.planning' : 'app.running')
|
|
77
88
|
: app.stale ? 'app.stale'
|
|
@@ -79,6 +90,7 @@
|
|
|
79
90
|
: !ready ? 'app.needValidated'
|
|
80
91
|
: !app.savedFile || !app.savedHash ? 'app.needSaved'
|
|
81
92
|
: !$('target-select').value ? 'app.needTarget'
|
|
93
|
+
: !compatibleTarget() ? 'app.platformMismatch'
|
|
82
94
|
: !$('review-checkbox').checked ? 'app.needReview' : 'app.ready');
|
|
83
95
|
const cases = app.draft?.cases || [];
|
|
84
96
|
const blocked = cases.filter((item) => item.questions?.length || (item.steps.some((step) => ['act', 'assertAgent'].includes(step.type)) && !app.state?.models?.agent?.keyReady)).length;
|
|
@@ -86,7 +98,7 @@
|
|
|
86
98
|
const pill = $('draft-status');
|
|
87
99
|
pill.textContent = t(app.stale ? 'app.regenerate' : app.dirty ? 'app.revalidate' : ready ? 'app.validated' : 'scenario.noDraft');
|
|
88
100
|
pill.className = 'status-pill' + (ready ? ' ready' : app.stale || app.dirty ? ' dirty' : '');
|
|
89
|
-
$('draft-source').textContent = t(app.origin === 'generated' ? 'app.sourceGenerated' : app.origin === 'library' ? (app.sourceFlow ? 'app.sourceLibraryFlow' : 'app.sourceLibrary') : app.origin === 'manual' ? (app.sourceFlow ? 'app.sourceManualFlow' : 'app.sourceManual') : 'scenario.sourceNone');
|
|
101
|
+
$('draft-source').textContent = t(app.origin === 'generated' ? 'app.sourceGenerated' : app.origin === 'library' ? (app.sourceFlow ? 'app.sourceLibraryFlow' : 'app.sourceLibrary') : app.origin === 'imported' ? (app.sourceFlow ? 'app.sourceImportedFlow' : 'app.sourceImported') : app.origin === 'manual' ? (app.sourceFlow ? 'app.sourceManualFlow' : 'app.sourceManual') : 'scenario.sourceNone');
|
|
90
102
|
$('script-preview').textContent = ready ? app.script : t('scenario.scriptPlaceholder');
|
|
91
103
|
}
|
|
92
104
|
function locatorText(locator) {
|
|
@@ -98,6 +110,9 @@
|
|
|
98
110
|
const target = locatorText(step.locator);
|
|
99
111
|
switch (step.type) {
|
|
100
112
|
case 'open': return t('app.stepOpen', { path: step.path });
|
|
113
|
+
case 'launchApp': return t('app.stepLaunchApp');
|
|
114
|
+
case 'back': return t('app.stepBack');
|
|
115
|
+
case 'swipe': return t('app.stepSwipe', { direction: step.direction });
|
|
101
116
|
case 'click': return t('app.stepClick', { target });
|
|
102
117
|
case 'fill': return t('app.stepFill', { target, value: step.value });
|
|
103
118
|
case 'assertVisible': return t(step.visible === false ? 'app.stepHidden' : 'app.stepVisible', { target });
|
|
@@ -137,23 +152,24 @@
|
|
|
137
152
|
function setDraft(suite, script, origin, sourceFlow = '', savedFile = '', savedHash = '') {
|
|
138
153
|
app.draft = suite; app.script = script; app.origin = origin; app.sourceFlow = sourceFlow; app.boundSource = suite.source ? { ...suite.source } : null; app.savedFile = savedFile; app.savedHash = savedHash; app.stale = false; app.dirty = false;
|
|
139
154
|
$('suite-editor').value = JSON.stringify(suite, null, 2);
|
|
155
|
+
$('scenario-platform').value = suite.platform || 'web';
|
|
140
156
|
$('review-checkbox').checked = false;
|
|
141
157
|
summarize(suite); readiness();
|
|
142
158
|
}
|
|
143
159
|
function targetOptions() {
|
|
144
160
|
const selected = $('target-select').value;
|
|
145
|
-
const
|
|
161
|
+
const previousFingerprint = app.displayTargets.find((item) => item.name === selected)?.targetFingerprint;
|
|
146
162
|
app.displayTargets = (app.state?.targets || []).map((target) => ({ ...target }));
|
|
147
163
|
replace($('target-select'), el('option', '', t('scenario.selectTarget')));
|
|
148
164
|
$('target-select').firstChild.value = '';
|
|
149
|
-
for (const target of app.state?.targets || []) { const option = el('option', '', target
|
|
165
|
+
for (const target of app.state?.targets || []) { const option = el('option', '', targetDisplay(target)); option.value = target.name; $('target-select').append(option); }
|
|
150
166
|
if ((app.state?.targets || []).some((x) => x.name === selected)) $('target-select').value = selected;
|
|
151
|
-
if (
|
|
167
|
+
if (previousFingerprint !== app.displayTargets.find((item) => item.name === selected)?.targetFingerprint) $('review-checkbox').checked = false;
|
|
152
168
|
targetDescription();
|
|
153
169
|
const list = $('target-list'); replace(list);
|
|
154
|
-
for (const target of app.state?.targets || []) { const row = el('div', 'settings-entry'); row.append(el('strong', '', target.name), document.createTextNode(` · ${target
|
|
170
|
+
for (const target of app.state?.targets || []) { const row = el('div', 'settings-entry'); row.append(el('strong', '', target.name), document.createTextNode(` · ${targetLabel(target)}${target.description ? ` · ${target.description}` : ''}`)); list.append(row); }
|
|
155
171
|
}
|
|
156
|
-
function targetDescription() { const target =
|
|
172
|
+
function targetDescription() { const target = selectedTarget(); $('target-description').textContent = target ? `${targetLabel(target)}${target.description ? ` · ${target.description}` : ''}` : t('scenario.targetHint'); readiness(); }
|
|
157
173
|
function modelDescription(role) {
|
|
158
174
|
const model = app.state?.models?.[role] || {};
|
|
159
175
|
const explanation = t(role === 'planner' ? 'app.modelPlannerHelp' : 'app.modelAgentHelp');
|
|
@@ -192,7 +208,7 @@
|
|
|
192
208
|
const runs = app.state?.runs || [];
|
|
193
209
|
if (!runs.length) { host.append(el('p', 'empty', t(app.state?.active ? 'app.historyActiveEmpty' : 'app.historyEmpty'))); return; }
|
|
194
210
|
for (const run of runs) {
|
|
195
|
-
const row = el('div', 'list-row'); const copy = el('div'); copy.append(el('strong', '', `${run.suiteId} · ${i18n.status(run.status)}`), el('small', '', t('app.runMeta', { date: formatDate(run.startedAt), target: run.target
|
|
211
|
+
const row = el('div', 'list-row'); const copy = el('div'); copy.append(el('strong', '', `${run.suiteId} · ${i18n.status(run.status)}`), el('small', '', t('app.runMeta', { date: formatDate(run.startedAt), target: targetDisplay(run.target), passed: run.passedCount ?? 0, total: run.caseCount ?? 0, id: run.runId })));
|
|
196
212
|
const button = el('button', 'button', t('app.viewReport')); button.type = 'button'; button.disabled = run.status === 'running' || run.status === 'stopping'; button.addEventListener('click', () => loadReport(run.runId)); row.append(copy, button); host.append(row);
|
|
197
213
|
}
|
|
198
214
|
}
|
|
@@ -226,16 +242,38 @@
|
|
|
226
242
|
const flow = $('flow-input').value.trim();
|
|
227
243
|
if (!flow) throw localizedError('app.enterFlow');
|
|
228
244
|
if (!app.state?.models?.planner?.configured || app.state.models.planner.kind !== 'openai-compatible' || !app.state.models.planner.keyReady) throw localizedError('app.configurePlanner');
|
|
229
|
-
const request = ++app.planRequest;
|
|
245
|
+
const request = ++app.planRequest; const platform = $('scenario-platform').value;
|
|
230
246
|
app.planning = true; renderActive(); noticeKey('app.planStarted');
|
|
231
247
|
try {
|
|
232
|
-
const result = await api('/api/plan', { flow });
|
|
248
|
+
const result = await api('/api/plan', { flow, platform });
|
|
233
249
|
if (request !== app.planRequest) { noticeKey('app.planCancelledLate'); return; }
|
|
234
250
|
setDraft(result.suite, result.script, 'generated', flow); noticeKey('app.planComplete');
|
|
235
251
|
} catch (error) { if (request !== app.planRequest) noticeKey('app.planCancelledDraft'); else throw error; }
|
|
236
252
|
finally { app.planning = false; await refresh(false).catch(() => {}); }
|
|
237
253
|
}); }
|
|
238
|
-
async function prompt() { await work(async () => { const flow = $('flow-input').value.trim(); if (!flow) throw localizedError('app.enterFlow'); const result = await api('/api/prompt', { flow }); download('flow-qa-prompt.md', result.prompt, 'text/markdown'); noticeKey('app.promptDownloaded'); }); }
|
|
254
|
+
async function prompt() { await work(async () => { const flow = $('flow-input').value.trim(); if (!flow) throw localizedError('app.enterFlow'); const result = await api('/api/prompt', { flow, platform: $('scenario-platform').value }); download('flow-qa-prompt.md', result.prompt, 'text/markdown'); noticeKey('app.promptDownloaded'); }); }
|
|
255
|
+
function editableFlow(flow) { return flow.replace(/\r\n?/g, '\n'); }
|
|
256
|
+
async function importFiles() {
|
|
257
|
+
const files = [...$('import-json').files, ...$('import-markdown').files];
|
|
258
|
+
let imported = false;
|
|
259
|
+
await work(async () => {
|
|
260
|
+
const jsonFiles = files.filter((file) => file.name.toLowerCase().endsWith('.json'));
|
|
261
|
+
const markdownFiles = files.filter((file) => file.name.toLowerCase().endsWith('.md'));
|
|
262
|
+
if (files.length > 2 || jsonFiles.length !== 1 || markdownFiles.length > 1 || jsonFiles.length + markdownFiles.length !== files.length) throw localizedError('app.importSelection');
|
|
263
|
+
if (jsonFiles[0].size > 256 * 1024 || (markdownFiles[0] && markdownFiles[0].size > 128 * 1024)) throw localizedError('app.importTooLarge');
|
|
264
|
+
let suite;
|
|
265
|
+
try { suite = JSON.parse(await jsonFiles[0].text()); } catch { throw localizedError('app.invalidJson'); }
|
|
266
|
+
const flow = markdownFiles[0] ? await markdownFiles[0].text() : undefined;
|
|
267
|
+
const body = { suite, ...(flow === undefined ? {} : { flow }) };
|
|
268
|
+
if (new TextEncoder().encode(JSON.stringify(body)).byteLength > 256 * 1024) throw localizedError('app.importTooLarge');
|
|
269
|
+
const result = await api('/api/import', body);
|
|
270
|
+
$('flow-input').value = flow || '';
|
|
271
|
+
setDraft(result.suite, result.script, 'imported', flow || '');
|
|
272
|
+
noticeKey('app.importComplete');
|
|
273
|
+
imported = true;
|
|
274
|
+
});
|
|
275
|
+
if (imported) { $('import-json').value = ''; $('import-markdown').value = ''; }
|
|
276
|
+
}
|
|
239
277
|
async function compile() { await work(async () => {
|
|
240
278
|
if (app.stale) throw localizedError('app.compileStale');
|
|
241
279
|
let suite; try { suite = JSON.parse($('suite-editor').value); } catch { throw localizedError('app.invalidJson'); }
|
|
@@ -248,8 +286,8 @@
|
|
|
248
286
|
setDraft(result.suite, result.script, app.origin === 'none' ? 'manual' : app.origin, sourceFlow, app.dirty ? '' : app.savedFile, app.dirty ? '' : app.savedHash);
|
|
249
287
|
noticeKey('app.compileComplete');
|
|
250
288
|
}); }
|
|
251
|
-
async function save() { await work(async () => { if (!app.draft || app.dirty || app.stale) throw localizedError('app.needValidated'); const body = { suite: app.draft }; if (!app.boundSource && app.sourceFlow && app.sourceFlow === $('flow-input').value.trim()) body.flow = app.sourceFlow; const result = await api('/api/save', body); const saved = await api(`/api/suite?file=${encodeURIComponent(result.file)}`); $('flow-input').value = saved.flow || ''; setDraft(saved.suite, saved.script, 'library', saved.flow || '', result.file, saved.suiteHash || ''); noticeKey('app.savedSuite', { file: result.file }); await refresh(false); }); }
|
|
252
|
-
async function run() { await work(async () => { if (!app.draft || !app.script || !app.savedFile || !app.savedHash || app.dirty || app.stale || !$('review-checkbox').checked ||
|
|
289
|
+
async function save() { await work(async () => { if (!app.draft || app.dirty || app.stale) throw localizedError('app.needValidated'); const body = { suite: app.draft }; if (!app.boundSource && app.sourceFlow && (app.origin === 'imported' ? editableFlow(app.sourceFlow) === $('flow-input').value : app.sourceFlow === $('flow-input').value.trim())) body.flow = app.sourceFlow; const result = await api('/api/save', body); const saved = await api(`/api/suite?file=${encodeURIComponent(result.file)}`); $('flow-input').value = saved.flow || ''; setDraft(saved.suite, saved.script, 'library', saved.flow || '', result.file, saved.suiteHash || ''); noticeKey('app.savedSuite', { file: result.file }); await refresh(false); }); }
|
|
290
|
+
async function run() { await work(async () => { if (!app.draft || !app.script || !app.savedFile || !app.savedHash || app.dirty || app.stale || !$('review-checkbox').checked || !compatibleTarget()) throw localizedError('app.runNotReady'); const target = selectedTarget(); const result = await api('/api/run', { suite: app.savedFile, suiteHash: app.savedHash, targetName: target.name, targetFingerprint: target.targetFingerprint }); noticeKey('app.runStarted', { id: result.operationId }); await refresh(false); }); }
|
|
253
291
|
async function stop() {
|
|
254
292
|
if (app.stopping || !app.state?.active) return;
|
|
255
293
|
if (app.state.active.kind === 'plan' && app.planning) app.planRequest += 1;
|
|
@@ -271,9 +309,9 @@
|
|
|
271
309
|
}
|
|
272
310
|
function renderReport() {
|
|
273
311
|
const report = app.report; if (!report) return;
|
|
274
|
-
$('report-heading').textContent = `${report.runId} · ${i18n.status(report.status)} · ${report.target
|
|
312
|
+
$('report-heading').textContent = `${report.runId} · ${i18n.status(report.status)} · ${targetDisplay(report.target)}`;
|
|
275
313
|
const host = $('report-content'); replace(host);
|
|
276
|
-
const meta = el('div', 'report-meta'); for (const value of [t('app.reportStatus', { status: i18n.status(report.status) }), t('app.reportTarget', { target: report.target
|
|
314
|
+
const meta = el('div', 'report-meta'); for (const value of [t('app.reportStatus', { status: i18n.status(report.status) }), t('app.reportTarget', { target: targetDisplay(report.target) }), t('app.reportStarted', { date: formatDate(report.startedAt) }), t('app.reportFinished', { date: formatDate(report.finishedAt) })]) meta.append(el('span', '', value)); host.append(meta);
|
|
277
315
|
if (report.usage) host.append(el('p', 'hint', t('app.usage', { tokens: report.usage.modelTokens ?? t('app.unavailable'), cost: typeof report.usage.estimatedCostUsd === 'number' ? `$${report.usage.estimatedCostUsd.toFixed(4)}` : t('app.unavailable') })));
|
|
278
316
|
if (report.errors?.length) host.append(el('p', 'report-errors', report.errors.join('\n')));
|
|
279
317
|
for (const item of report.cases || []) {
|
|
@@ -302,7 +340,8 @@
|
|
|
302
340
|
}
|
|
303
341
|
async function saveModel(event) { event.preventDefault(); const form = event.currentTarget; await work(async () => { const values = new FormData(form); const body = { role: form.dataset.role, baseURL: String(values.get('baseURL') || '').trim(), model: String(values.get('model') || '').trim() }; const apiKeyEnv = String(values.get('apiKeyEnv') || '').trim(); if (apiKeyEnv) body.apiKeyEnv = apiKeyEnv; await api('/api/models', body); await refresh(true); noticeKey('app.modelSaved'); }); }
|
|
304
342
|
async function clearModel(role) { await work(async () => { await api('/api/models', { role, clear: true }); await refresh(true); noticeKey('app.modelCleared'); }); }
|
|
305
|
-
|
|
343
|
+
function targetFields() { const mobile = $('target-kind').value !== 'web'; for (const [id, required] of [['target-url-field', !mobile], ['target-bundle-field', mobile], ['target-device-field', mobile]]) { const field = $(id); field.hidden = !required; field.querySelector('input').required = required; } }
|
|
344
|
+
async function saveTarget(event) { event.preventDefault(); const form = event.currentTarget; await work(async () => { const values = new FormData(form); const kind = String(values.get('kind') || 'web'); const body = { name: String(values.get('name') || '').trim(), description: String(values.get('description') || '').trim() }; if (kind === 'web') body.url = String(values.get('url') || '').trim(); else { body.kind = 'mobile'; body.platform = kind; body.bundleId = String(values.get('bundleId') || '').trim(); body.device = String(values.get('device') || '').trim(); } await api('/api/targets', body); form.reset(); targetFields(); await refresh(true); noticeKey('app.targetSaved'); }); }
|
|
306
345
|
function renderLanguage() {
|
|
307
346
|
const editedModels = [...document.querySelectorAll('.model-form')].map((form) => ({ role: form.dataset.role, values: Object.fromEntries([...form.querySelectorAll('input')].map((input) => [input.name, input.value])) }));
|
|
308
347
|
const focusedModel = document.activeElement?.closest?.('.model-form');
|
|
@@ -310,6 +349,8 @@
|
|
|
310
349
|
$('breadcrumb').textContent = t('app.breadcrumb', { name: t(names[app.view]) });
|
|
311
350
|
const emptyTarget = $('target-select').querySelector('option[value=""]');
|
|
312
351
|
if (emptyTarget) emptyTarget.textContent = t('scenario.selectTarget');
|
|
352
|
+
for (const option of $('target-select').options) { const target = app.displayTargets.find((item) => item.name === option.value); if (target) option.textContent = targetDisplay(target); }
|
|
353
|
+
const targetList = $('target-list'); for (const [row, target] of [...targetList.children].map((node, index) => [node, app.displayTargets[index]])) { if (target) row.replaceChildren(el('strong', '', target.name), document.createTextNode(` · ${targetLabel(target)}${target.description ? ` · ${target.description}` : ''}`)); }
|
|
313
354
|
modelForms();
|
|
314
355
|
for (const edited of editedModels) {
|
|
315
356
|
const form = [...document.querySelectorAll('.model-form')].find((item) => item.dataset.role === edited.role);
|
|
@@ -339,16 +380,19 @@
|
|
|
339
380
|
noticeKey('app.newDraftNotice');
|
|
340
381
|
$('suite-editor').focus();
|
|
341
382
|
});
|
|
342
|
-
$('flow-input').addEventListener('input', () => { if ((app.sourceFlow || app.boundSource) && $('flow-input').value.trim() !== app.sourceFlow.trim()) { app.stale = true; app.savedHash = ''; $('review-checkbox').checked = false; readiness(); } });
|
|
383
|
+
$('flow-input').addEventListener('input', () => { if (app.origin === 'imported' ? $('flow-input').value !== editableFlow(app.sourceFlow) : (app.sourceFlow || app.boundSource) && $('flow-input').value.trim() !== editableFlow(app.sourceFlow).trim()) { app.stale = true; app.savedHash = ''; $('review-checkbox').checked = false; readiness(); } });
|
|
384
|
+
$('scenario-platform').addEventListener('change', () => { app.planRequest += 1; if (app.draft) { app.stale = true; app.savedHash = ''; } $('review-checkbox').checked = false; readiness(); });
|
|
343
385
|
$('suite-editor').addEventListener('input', () => { app.dirty = true; app.script = ''; app.savedFile = ''; app.savedHash = ''; if (app.origin === 'none') app.origin = 'manual'; $('review-checkbox').checked = false; summarize(null); readiness(); });
|
|
344
386
|
$('review-checkbox').addEventListener('change', readiness); $('target-select').addEventListener('change', () => { $('review-checkbox').checked = false; targetDescription(); });
|
|
345
387
|
$('plan-btn').addEventListener('click', generate); $('prompt-btn').addEventListener('click', prompt); $('compile-btn').addEventListener('click', compile); $('save-btn').addEventListener('click', save); $('run-btn').addEventListener('click', run); $('stop-btn').addEventListener('click', stop);
|
|
388
|
+
$('import-btn').addEventListener('click', importFiles);
|
|
346
389
|
$('global-stop-btn').addEventListener('click', stop);
|
|
347
390
|
$('reconnect-btn').addEventListener('click', () => refresh(true).catch(showError));
|
|
348
391
|
$('script-download').addEventListener('click', () => { if (app.draft && !app.dirty && !app.stale) download(`${app.draft.id || 'flow-qa'}.test.mjs`, app.script, 'text/javascript'); });
|
|
349
392
|
$('report-md').addEventListener('click', downloadReportMarkdown);
|
|
350
393
|
$('report-json').addEventListener('click', () => { if (app.report) download(`${app.selectedRun}.json`, JSON.stringify(app.report, null, 2), 'application/json'); });
|
|
351
394
|
$('target-form').addEventListener('submit', saveTarget);
|
|
395
|
+
$('target-kind').addEventListener('change', targetFields); targetFields();
|
|
352
396
|
$('language-select').addEventListener('change', (event) => i18n.setLocale(event.currentTarget.value));
|
|
353
397
|
i18n.onChange(renderLanguage);
|
|
354
398
|
let refreshing = false;
|
|
@@ -23,6 +23,10 @@
|
|
|
23
23
|
'scenario.eyebrow': ['01 / SCENARIO', '01 / 시나리오'],
|
|
24
24
|
'scenario.headingSecond': ['keep the evidence.', '검증을 남깁니다.'],
|
|
25
25
|
'scenario.intro': ['Define the expected behavior, review the cases, then run them against a selected target.', '기대 동작을 정의하고 케이스를 검토한 뒤, 선택한 대상에서 실행하세요.'],
|
|
26
|
+
'scenario.platform': ['Case platform', '케이스 플랫폼'],
|
|
27
|
+
'scenario.web': ['Web', '웹'],
|
|
28
|
+
'scenario.mobile': ['Mobile app', '모바일 앱'],
|
|
29
|
+
'scenario.mobileSetup': ['For iOS, prepare Xcode, a simulator or signed device, and app signing. For Android, prepare the Android SDK and an emulator or authorized device. Configuration alone does not verify device access.', 'iOS는 Xcode, 시뮬레이터 또는 서명된 기기와 앱 서명을 준비하세요. Android는 Android SDK와 에뮬레이터 또는 승인된 기기를 준비하세요. 설정만으로 기기 접근이 검증되지는 않습니다.'],
|
|
26
30
|
'scenario.flowTitle': ['Flow to test', '검증할 흐름'],
|
|
27
31
|
'scenario.flowHelp': ['Describe the action and the result you expect to see.', '어떤 행동에 어떤 결과가 보여야 하는지 적어주세요.'],
|
|
28
32
|
'scenario.flowLabel': ['Scenario and expected behavior', '시나리오와 기대 동작'],
|
|
@@ -32,6 +36,10 @@
|
|
|
32
36
|
'scenario.prompt': ['Download generation prompt', '생성 프롬프트 다운로드'],
|
|
33
37
|
'scenario.reviewTitle': ['Review cases', '케이스 검토'],
|
|
34
38
|
'scenario.reviewHelp': ['Open each case to inspect its actions and expected results.', '각 케이스를 펼쳐 행동과 기대 결과를 살펴보세요.'],
|
|
39
|
+
'scenario.importFiles': ['Import case files', '케이스 파일 가져오기'],
|
|
40
|
+
'scenario.importJson': ['TC JSON', 'TC JSON'],
|
|
41
|
+
'scenario.importMarkdown': ['Source Markdown (optional)', '원본 Markdown (선택)'],
|
|
42
|
+
'scenario.importHint': ['Select one TC JSON and, if needed, its source Markdown separately. Import creates a copy to save and review.', 'TC JSON 1개와 필요한 경우 원본 Markdown을 각각 선택하세요. 검토하고 저장할 복사본을 가져옵니다.'],
|
|
35
43
|
'scenario.noDraft': ['No draft', '초안 없음'],
|
|
36
44
|
'scenario.summaryEmpty': ['No cases to review yet. Load a local example from the library or generate cases from the scenario.', '아직 검토할 케이스가 없습니다. 보관함의 로컬 예제를 불러오거나 왼쪽 시나리오에서 생성하세요.'],
|
|
37
45
|
'scenario.sourceNone': ['Source: none', '원본: 없음'],
|
|
@@ -98,7 +106,14 @@
|
|
|
98
106
|
'settings.savedHere': ['Your display choices are saved in this browser.', '선택한 화면 설정은 이 브라우저에 저장됩니다.'],
|
|
99
107
|
'settings.reset': ['Reset to defaults', '기본값으로 재설정'],
|
|
100
108
|
'settings.targets': ['Run targets', '실행 대상'],
|
|
101
|
-
'settings.targetsHelp': ['Add
|
|
109
|
+
'settings.targetsHelp': ['Add a web address or a native app and device.', '웹 주소 또는 네이티브 앱과 기기를 등록하세요.'],
|
|
110
|
+
'settings.targetType': ['Target type', '대상 유형'],
|
|
111
|
+
'settings.ios': ['iOS app', 'iOS 앱'],
|
|
112
|
+
'settings.android': ['Android app', 'Android 앱'],
|
|
113
|
+
'settings.targetUrl': ['URL', 'URL'],
|
|
114
|
+
'settings.bundleId': ['App bundle ID / package name', '앱 번들 ID / 패키지 이름'],
|
|
115
|
+
'settings.device': ['Device name or ID', '기기 이름 또는 ID'],
|
|
116
|
+
'settings.targetSafety': ['Saving a target does not connect a device or install an app.', '대상을 저장해도 기기가 연결되거나 앱이 설치되지는 않습니다.'],
|
|
102
117
|
'settings.targetName': ['Target name', '대상 이름'],
|
|
103
118
|
'settings.targetNamePlaceholder': ['e.g. local-demo', '예: local-demo'],
|
|
104
119
|
'settings.description': ['Description', '설명'],
|
|
@@ -126,6 +141,7 @@
|
|
|
126
141
|
'app.needValidated': ['Validated cases required.', '검증된 케이스가 필요합니다.'],
|
|
127
142
|
'app.needSaved': ['Save cases or reload them from the library before running.', '실행 전 케이스를 저장하거나 보관함에서 다시 불러오세요.'],
|
|
128
143
|
'app.needTarget': ['Select a run target.', '실행 대상을 선택하세요.'],
|
|
144
|
+
'app.platformMismatch': ['The case platform and target type must match.', '케이스 플랫폼과 대상 유형이 일치해야 합니다.'],
|
|
129
145
|
'app.needReview': ['Review the contents, then select the checkbox.', '내용 검토 후 확인란을 선택하세요.'],
|
|
130
146
|
'app.ready': ['Ready to run.', '실행할 준비가 됐습니다.'],
|
|
131
147
|
'app.caseReadiness': ['{total} total · {ready} runnable · {blocked} blocked before run{hint}', '전체 {total}개 · 실행 가능 {ready}개 · 사전 차단 {blocked}개{hint}'],
|
|
@@ -136,6 +152,8 @@
|
|
|
136
152
|
'app.sourceGenerated': ['Source: AI generated from this scenario · JSON can be reviewed', '원본: 현재 시나리오에서 AI 생성 · JSON 검토 가능'],
|
|
137
153
|
'app.sourceLibraryFlow': ['Source: saved cases and linked scenario', '원본: 저장된 케이스와 연결된 시나리오'],
|
|
138
154
|
'app.sourceLibrary': ['Source: saved cases · no linked scenario', '원본: 저장된 케이스 · 연결된 시나리오 없음'],
|
|
155
|
+
'app.sourceImportedFlow': ['Source: imported copy with source Markdown · save and review required', '원본: Markdown과 함께 가져온 복사본 · 저장 및 검토 필요'],
|
|
156
|
+
'app.sourceImported': ['Source: imported case copy · save and review required', '원본: 가져온 케이스 복사본 · 저장 및 검토 필요'],
|
|
139
157
|
'app.sourceManualFlow': ['Source: current scenario · manually written JSON', '원본: 현재 시나리오 · 직접 작성한 JSON'],
|
|
140
158
|
'app.sourceManual': ['Source: manually written JSON · no linked scenario', '원본: 직접 작성한 JSON · 연결된 시나리오 없음'],
|
|
141
159
|
'app.locatorRole': ['role', '역할'],
|
|
@@ -144,6 +162,9 @@
|
|
|
144
162
|
'app.locatorTestId': ['test ID', '테스트 ID'],
|
|
145
163
|
'app.locatorName': ['name “{name}”', '이름 “{name}”'],
|
|
146
164
|
'app.stepOpen': ['Open page: {path}', '페이지 열기: {path}'],
|
|
165
|
+
'app.stepLaunchApp': ['Launch app', '앱 실행'],
|
|
166
|
+
'app.stepBack': ['Go back', '뒤로 가기'],
|
|
167
|
+
'app.stepSwipe': ['Swipe {direction}', '{direction} 방향으로 스와이프'],
|
|
147
168
|
'app.stepClick': ['Click: {target}', '클릭: {target}'],
|
|
148
169
|
'app.stepFill': ['Fill: {target} → “{value}”', '입력: {target} → “{value}”'],
|
|
149
170
|
'app.stepVisible': ['Expected: {target} visible', '기대 결과: {target} 표시됨'],
|
|
@@ -196,8 +217,8 @@
|
|
|
196
217
|
'app.connected': ['Local server connected', '로컬 서버 연결됨'],
|
|
197
218
|
'app.refreshSettings': ['Refresh settings', '설정 새로고침'],
|
|
198
219
|
'app.runtimeDisconnected': ['Connect to the server to check the runtime.', '서버 연결 후 실행 환경을 확인할 수 있습니다.'],
|
|
199
|
-
'app.runtimeInstalled': ['
|
|
200
|
-
'app.runtimeMissing': ['
|
|
220
|
+
'app.runtimeInstalled': ['Web browser installed · Fixed-action web cases can run without a model. Mobile device access is checked during a run.', '웹 브라우저 설치 확인됨 · 고정 조작 웹 케이스는 모델 없이 실행할 수 있습니다. 모바일 기기 접근은 실행 중 확인합니다.'],
|
|
221
|
+
'app.runtimeMissing': ['Web browser installation required for web runs. Run {command} in a terminal. Mobile runs check device access at runtime.', '웹 실행에는 브라우저 설치가 필요합니다. 터미널에서 {command}을 실행하세요. 모바일 실행은 실행 중 기기 접근을 확인합니다.'],
|
|
201
222
|
'app.plannerUnconfigured': ['AI planner is not connected. Configure a model in Connections or download the prompt.', 'AI 플래너가 연결되지 않았습니다. 연결 설정에서 모델을 지정하거나 프롬프트를 내려받으세요.'],
|
|
202
223
|
'app.plannerKeyMissing': ['The configured key environment variable is missing on the server. Add it and restart the server.', '설정한 키 환경 변수가 서버에 없습니다. 키를 주입한 뒤 서버를 재시작하세요.'],
|
|
203
224
|
'app.plannerCliOnly': ['This model can be used from the CLI. Connect an OpenAI-compatible model to generate cases in the UI.', '이 모델은 CLI에서 사용할 수 있습니다. UI에서 생성하려면 OpenAI 호환 모델을 연결하세요.'],
|
|
@@ -213,6 +234,9 @@
|
|
|
213
234
|
'app.promptDownloaded': ['Prompt downloaded. Paste the generated JSON and validate it.', '프롬프트를 다운로드했습니다. 생성된 JSON을 붙여 넣고 검증하세요.'],
|
|
214
235
|
'app.compileStale': ['Scenario changed. Generate cases again or reload them from the library.', '시나리오가 변경됐습니다. 다시 생성하거나 보관함의 케이스를 불러오세요.'],
|
|
215
236
|
'app.invalidJson': ['Check the JSON syntax. Use double quotes around property names and strings, and check commas and brackets.', 'JSON 구문을 확인하세요. 속성 이름과 문자열은 큰따옴표로 감싸고 쉼표·괄호를 확인해 주세요.'],
|
|
237
|
+
'app.importSelection': ['Select exactly one TC JSON and at most one source Markdown file.', 'TC JSON 파일 1개와 원본 Markdown 파일 최대 1개를 선택하세요.'],
|
|
238
|
+
'app.importTooLarge': ['The selected files are too large to import. JSON must be at most 256 KiB, Markdown at most 128 KiB, and the request at most 256 KiB.', '선택한 파일이 너무 큽니다. JSON은 최대 256 KiB, Markdown은 최대 128 KiB, 요청 전체는 최대 256 KiB여야 합니다.'],
|
|
239
|
+
'app.importComplete': ['Case copy imported. Review the cases and source, then save before running.', '케이스 복사본을 가져왔습니다. 케이스와 원본을 검토한 뒤 실행 전에 저장하세요.'],
|
|
216
240
|
'app.compileComplete': ['JSON validated and script generated. The linked source scenario was kept.', 'JSON 검증과 스크립트 생성이 완료됐습니다. 연결된 원본 시나리오는 유지됩니다.'],
|
|
217
241
|
'app.savedSuite': ['Cases saved to a separate file: {file}. Review them again before running.', '케이스를 별도 파일로 저장했습니다: {file}. 실행 전 내용을 다시 검토하세요.'],
|
|
218
242
|
'app.runNotReady': ['Save or reload cases, select a target, and review the contents before running.', '케이스 저장 또는 다시 불러오기, 대상 선택, 직접 검토가 필요합니다.'],
|
|
@@ -48,6 +48,8 @@
|
|
|
48
48
|
<section class="work-section flow-section" aria-labelledby="flow-title">
|
|
49
49
|
<div class="section-head"><span class="section-number">01</span><div><h2 id="flow-title" data-i18n="scenario.flowTitle">Flow to test</h2><p data-i18n="scenario.flowHelp">Describe the action and the result you expect to see.</p></div></div>
|
|
50
50
|
<div class="section-body">
|
|
51
|
+
<label for="scenario-platform" class="field-label" data-i18n="scenario.platform">Case platform</label>
|
|
52
|
+
<select id="scenario-platform"><option value="web" data-i18n="scenario.web">Web</option><option value="mobile" data-i18n="scenario.mobile">Mobile app</option></select>
|
|
51
53
|
<label for="flow-input" class="field-label" data-i18n="scenario.flowLabel">Scenario and expected behavior</label>
|
|
52
54
|
<textarea id="flow-input" rows="11" placeholder="For example: When a user enters a title and selects Add, the same title appears in the list. An empty title shows an error." data-i18n-placeholder="scenario.flowPlaceholder"></textarea>
|
|
53
55
|
<p class="hint" data-i18n="scenario.flowHint">Generate cases when an AI planner is ready. You can also download the prompt and write them yourself without a model connection.</p>
|
|
@@ -59,6 +61,8 @@
|
|
|
59
61
|
<div class="section-head"><span class="section-number">02</span><div><h2 id="draft-title" data-i18n="scenario.reviewTitle">Review cases</h2><p data-i18n="scenario.reviewHelp">Open each case to inspect its actions and expected results.</p></div><span id="draft-status" class="status-pill" data-i18n="scenario.noDraft">No draft</span></div>
|
|
60
62
|
<div class="section-body">
|
|
61
63
|
<div class="draft-topline"><span id="draft-source" data-i18n="scenario.sourceNone">Source: none</span><button type="button" class="text-button" id="library-shortcut"><span data-i18n="scenario.fromLibrary">Load from library</span><span aria-hidden="true">↗</span></button></div>
|
|
64
|
+
<div class="draft-actions import-actions"><label for="import-json"><span data-i18n="scenario.importJson">TC JSON</span><input id="import-json" type="file" accept=".json"></label><label for="import-markdown"><span data-i18n="scenario.importMarkdown">Source Markdown (optional)</span><input id="import-markdown" type="file" accept=".md"></label><button id="import-btn" type="button" class="button quiet" data-i18n="scenario.importFiles">Import case files</button></div>
|
|
65
|
+
<p class="hint" data-i18n="scenario.importHint">Select one TC JSON and, if needed, its source Markdown separately. Import creates a copy to save and review.</p>
|
|
62
66
|
<div id="draft-summary" class="summary" aria-live="polite"><p class="summary-empty" data-i18n="scenario.summaryEmpty">No cases to review yet. Load a local example from the library or generate cases from the scenario.</p></div>
|
|
63
67
|
<div class="draft-actions"><button id="save-btn" type="button" class="button" data-i18n="scenario.save">Save cases separately</button><span data-i18n="scenario.saveHint">Only saved cases can be run.</span></div>
|
|
64
68
|
<details id="technical-details" class="technical-details">
|
|
@@ -76,7 +80,7 @@
|
|
|
76
80
|
<section class="execution-section" aria-labelledby="run-title">
|
|
77
81
|
<div class="section-head"><span class="section-number">03</span><div><h2 id="run-title" data-i18n="scenario.runTitle">Review, then run</h2><p data-i18n="scenario.runHelp">Select a target and confirm you have reviewed the cases.</p></div></div>
|
|
78
82
|
<div class="execution-grid">
|
|
79
|
-
<div class="target-field"><label for="target-select" class="field-label" data-i18n="scenario.target">Run target</label><select id="target-select"><option value="" data-i18n="scenario.selectTarget">Select a target</option></select><p id="target-description" class="hint" data-i18n="scenario.targetHint">You can add a target in Connections.</p></div>
|
|
83
|
+
<div class="target-field"><label for="target-select" class="field-label" data-i18n="scenario.target">Run target</label><select id="target-select"><option value="" data-i18n="scenario.selectTarget">Select a target</option></select><p id="target-description" class="hint" data-i18n="scenario.targetHint">You can add a target in Connections.</p><p id="mobile-setup-hint" class="hint" hidden data-i18n="scenario.mobileSetup">For iOS, prepare Xcode, a simulator or signed device, and app signing. For Android, prepare the Android SDK and an emulator or authorized device. Configuration alone does not verify device access.</p></div>
|
|
80
84
|
<div class="execution-confirm"><p id="case-readiness" class="hint"></p><label class="review-line"><input type="checkbox" id="review-checkbox"><span data-i18n="scenario.reviewConfirm">I have reviewed the requirements, case steps, and expected results.</span></label><div class="run-bar"><span id="run-readiness" data-i18n="scenario.runInitial">Validated cases and a run target are required.</span><div class="actions"><button type="button" id="stop-btn" class="button quiet" hidden data-i18n="scenario.stop">Stop run</button><button type="button" id="run-btn" class="button primary" disabled><span data-i18n="scenario.run">Run QA</span><span aria-hidden="true">→</span></button></div></div></div>
|
|
81
85
|
</div>
|
|
82
86
|
</section>
|
|
@@ -106,7 +110,7 @@
|
|
|
106
110
|
<div class="hds-footer"><p id="preferences-status" aria-live="polite">Your display choices are saved in this browser.</p><button id="reset-preferences" type="button" data-i18n="settings.reset">Reset to defaults</button></div>
|
|
107
111
|
</section>
|
|
108
112
|
<div class="settings-grid">
|
|
109
|
-
<section class="settings-section"><div class="section-head"><span class="section-number">01</span><div><h2 data-i18n="settings.targets">Run targets</h2><p data-i18n="settings.targetsHelp">Add
|
|
113
|
+
<section class="settings-section"><div class="section-head"><span class="section-number">01</span><div><h2 data-i18n="settings.targets">Run targets</h2><p data-i18n="settings.targetsHelp">Add a web address or a native app and device.</p></div></div><form class="section-body form-stack" id="target-form"><label><span data-i18n="settings.targetName">Target name</span><input name="name" required placeholder="e.g. local-demo" data-i18n-placeholder="settings.targetNamePlaceholder" pattern="[A-Za-z0-9][A-Za-z0-9_\-]*"></label><label><span data-i18n="settings.targetType">Target type</span><select name="kind" id="target-kind"><option value="web" data-i18n="scenario.web">Web</option><option value="ios" data-i18n="settings.ios">iOS app</option><option value="android" data-i18n="settings.android">Android app</option></select></label><label id="target-url-field"><span data-i18n="settings.targetUrl">URL</span><input name="url" type="url" required placeholder="https://example.com"></label><label id="target-bundle-field" hidden><span data-i18n="settings.bundleId">App bundle ID / package name</span><input name="bundleId" autocomplete="off"></label><label id="target-device-field" hidden><span data-i18n="settings.device">Device name or ID</span><input name="device" autocomplete="off"></label><label><span data-i18n="settings.description">Description</span><textarea name="description" rows="3" placeholder="What this target is for" data-i18n-placeholder="settings.descriptionPlaceholder"></textarea></label><button class="button primary" type="submit" data-i18n="settings.saveTarget">Save target</button><p class="hint" data-i18n="settings.targetSafety">Saving a target does not connect a device or install an app.</p><div id="target-list" class="settings-list"></div></form></section>
|
|
110
114
|
<section class="settings-section"><div class="section-head"><span class="section-number">02</span><div><h2 data-i18n="settings.models">Model connections</h2><p data-i18n="settings.modelsHelp">Manage planner and run agent connections separately.</p></div></div><div class="section-body"><p class="hint" data-i18n="settings.modelsHint">Do not enter or store API key values here. Specify only the name of an environment variable in the server process.</p><div id="model-forms"></div></div></section>
|
|
111
115
|
</div>
|
|
112
116
|
</section>
|
|
@@ -108,6 +108,8 @@ button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-
|
|
|
108
108
|
.blocked-title { margin-top: 21px !important; color: var(--accent-text); }
|
|
109
109
|
.draft-actions { display: flex; align-items: center; flex-wrap: wrap; gap: 8px 14px; margin: calc(var(--space-factor) * 16px) 0; }
|
|
110
110
|
.draft-actions span { color: var(--muted); font-size: 12px; }
|
|
111
|
+
.import-actions label { display: flex; align-items: center; flex-wrap: wrap; gap: 4px 8px; min-width: 0; }
|
|
112
|
+
.import-actions input { min-width: 0; max-width: 100%; }
|
|
111
113
|
.technical-details { border-top: 1px solid var(--line); }
|
|
112
114
|
.technical-details > summary { display: flex; justify-content: space-between; gap: 10px; padding: 15px 0; color: var(--ink); font-size: 13px; font-weight: 750; cursor: pointer; list-style: none; }
|
|
113
115
|
.technical-details > summary::-webkit-details-marker { display: none; }
|
|
@@ -148,7 +150,7 @@ button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-
|
|
|
148
150
|
.preferences-section .hds-group { padding-block: calc(var(--space-factor) * 22px); }
|
|
149
151
|
.settings-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: clamp(25px, 3vw, 44px); }
|
|
150
152
|
.form-stack, .model-form { display: grid; gap: calc(var(--space-factor) * 15px); }
|
|
151
|
-
.form-stack label input, .form-stack label textarea, .model-form label input { margin-top: 8px; }
|
|
153
|
+
.form-stack label input, .form-stack label select, .form-stack label textarea, .model-form label input { margin-top: 8px; }
|
|
152
154
|
.form-stack > button { justify-self: start; }
|
|
153
155
|
.settings-list { margin-top: 11px; border-top: 1px solid var(--line); }
|
|
154
156
|
.settings-list:empty { display: none; }
|