open-design/e2e/tests/localized-content.test.ts
code-Y 84f768d4a2
feat: add WeChat design system, login-flow skill, and fix API mode tool_calls bug (#1083)
* feat: add WeChat design system, login-flow skill, and fix API mode tool_calls bug

- Add WeChat design system (design-systems/wechat/) with full brand spec
  including color palette, typography, and component rules for chat UI
- Add login-flow skill (skills/login-flow/) for mobile authentication flows
  with P0 checklist, example HTML, and i18n registration across 3 locales
- Fix DeepSeek V4 bug: API/BYOK mode (streamFormat=plain) models now receive
  a directive to emit only <artifact> HTML blocks and suppress tool_calls,
  since plain adapters proxy to external providers that cannot execute tools

* fix: restore full server.ts and WeChat DESIGN.md from ad46d8cd commit

Restore files that were corrupted in PR #1083 head branch.
The WeChat DESIGN.md was reduced to a single line (filename only)
and server.ts was reduced to ~1 line. Both are restored to their
original ad46d8cd state with full content.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix: restore full server.ts and WeChat DESIGN.md from ad46d8cd

Restore files corrupted in PR #1083:
- apps/daemon/src/server.ts: restored 7106-line file
- design-systems/wechat/DESIGN.md: restored 301-line WeChat design spec
- skills/login-flow/SKILL.md: restored from local working state
- skills/login-flow/example.html: restored 351-line example HTML

* fix: only suppress tool_calls when streamFormat='plain' explicitly, remove nonexistent assets/template.html

1. streamFormat check now requires explicit 'plain' value instead of defaulting
   to 'plain' when undefined. This prevents normal tool-using chat runs from
   incorrectly inheriting the API/BYOK tool_calls suppression rule.

2. login-flow SKILL.md: removed reference to assets/template.html since that file
   does not exist in the skill bundle and derivePreflight() would inject a hard
   instruction to read it before any other tool, causing pre-flight to fail.

* fix: thread streamFormat to composeSystemPrompt in server.ts call

Previously the composeSystemPrompt call at line ~4940 omitted streamFormat,
causing the composer to default to 'plain' and suppress tool_calls even
for tool-using chat runs. Now streamFormat is passed through from the
adapter definition so the API mode rule only fires when streamFormat='plain'
is explicitly set.

* fix: WeChat category metadata, font-family, and login-flow example interactivity

WeChat DESIGN.md:
- Add Category: Social & Messaging metadata so it appears correctly in picker
- Fix font-family declaration: remove invalid -webkit-font-family prefix,
  use standard font-family so downstream CSS generation works correctly

skills/login-flow/example.html:
- Add password toggle click handler so show/hide actually works
- Change Apple icon fill from hardcoded #fff to currentColor so it is
  visible on light backgrounds

* fix: mirror streamFormat suppression in contracts composer and add WeChat i18n

1. packages/contracts/src/prompts/system.ts: Add streamFormat parameter to
   ComposeInput and ComposeInput interface, mirroring the same suppression
   rule from daemon prompts/system.ts. When streamFormat='plain' is passed,
   a directive is appended telling models not to emit tool_calls and to only
   output <artifact> HTML blocks.

2. apps/web/src/i18n/content.{ts,fr,ru}.ts: Add WeChat design system entries:
   - Add 'wechat' to DE/FR/RU_DESIGN_SYSTEM_IDS_WITH_EN_FALLBACK arrays
   - Add 'wechat' summary to DE/FR/RU_DESIGN_SYSTEM_SUMMARIES
   - Add 'Social & Messaging' category to DE/FR/RU_DESIGN_SYSTEM_CATEGORIES
     (matching the Category: Social & Messaging metadata in WeChat DESIGN.md)

* fix: thread streamFormat='plain' into web composeSystemPrompt for api mode

* test: focus localized content coverage on missing resources

---------

Co-authored-by: Open Design Contributor <z@open-design.dev>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: mrcfps <mrc@powerformer.com>
2026-05-10 20:38:33 +08:00

199 lines
6.6 KiB
TypeScript

import { readdir, readFile, stat } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
declare global {
interface ImportMeta {
glob<T = unknown>(pattern: string, options: { eager: true }): Record<string, T>;
}
}
type LocalizedContentIds = {
skills: string[];
designSystems: string[];
designSystemCategories: string[];
promptTemplates: string[];
promptTemplateCategories: string[];
promptTemplateTags: string[];
};
type LocalizedContentModule = {
LOCALIZED_CONTENT_IDS: Record<string, LocalizedContentIds>;
};
const repoRoot = fileURLToPath(new URL('../../', import.meta.url));
const webContentModules = import.meta.glob<LocalizedContentModule>(
'../../apps/web/src/i18n/content.ts',
{ eager: true },
);
const localizedContentModule = Object.values(webContentModules)[0];
if (localizedContentModule == null) {
throw new Error('Failed to load apps/web localized content ids');
}
const { LOCALIZED_CONTENT_IDS } = localizedContentModule;
function sorted(values: Iterable<string>): string[] {
return [...values].sort((a, b) => a.localeCompare(b));
}
function uniqueSorted(values: Iterable<string>): string[] {
return sorted(new Set(values));
}
function findMissingIds(localizedIds: Iterable<string>, discoveredIds: Iterable<string>): string[] {
const localized = new Set(localizedIds);
const discovered = new Set(discoveredIds);
return sorted([...discovered].filter((id) => !localized.has(id)));
}
function expectExactResourceCoverage(
label: string,
localizedIds: Iterable<string>,
discoveredIds: Iterable<string>,
): void {
const missing = findMissingIds(localizedIds, discoveredIds);
expect(missing, `${label} should cover every discovered resource`).toEqual([]);
}
async function entriesWithFile(root: string, fileName: string): Promise<string[]> {
const entries = await readdir(root, { withFileTypes: true });
const ids: string[] = [];
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const filePath = path.join(root, entry.name, fileName);
try {
if ((await stat(filePath)).isFile()) {
ids.push(entry.name);
}
} catch {
// Missing optional registry files are ignored, matching resource discovery.
}
}
return sorted(ids);
}
async function readSkillIds(): Promise<string[]> {
const skillsRoot = path.join(repoRoot, 'skills');
const dirs = await entriesWithFile(skillsRoot, 'SKILL.md');
const ids = await Promise.all(
dirs.map(async (dir) => {
const raw = await readFile(path.join(skillsRoot, dir, 'SKILL.md'), 'utf8');
return readFrontmatterName(raw) ?? dir;
}),
);
return sorted(ids);
}
async function readDesignSystemIds(): Promise<string[]> {
return entriesWithFile(path.join(repoRoot, 'design-systems'), 'DESIGN.md');
}
async function readDesignSystemCategories(): Promise<string[]> {
const systemsRoot = path.join(repoRoot, 'design-systems');
const ids = await readDesignSystemIds();
const categories = await Promise.all(
ids.map(async (id) => {
const raw = await readFile(path.join(systemsRoot, id, 'DESIGN.md'), 'utf8');
return /^>\s*Category:\s*(.+?)\s*$/im.exec(raw)?.[1] ?? 'Uncategorized';
}),
);
return sorted(new Set(categories));
}
async function readPromptTemplateSummaries(): Promise<
Array<{ id: string; category: string; tags: string[] }>
> {
const templatesRoot = path.join(repoRoot, 'prompt-templates');
const summaries: Array<{ id: string; category: string; tags: string[] }> = [];
for (const surface of ['image', 'video']) {
const dir = path.join(templatesRoot, surface);
const entries = await readdir(dir, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isFile() || !entry.name.endsWith('.json')) continue;
const raw = JSON.parse(await readFile(path.join(dir, entry.name), 'utf8')) as {
id?: unknown;
category?: unknown;
tags?: unknown;
};
if (typeof raw.id !== 'string' || !raw.id) continue;
summaries.push({
id: raw.id,
category: typeof raw.category === 'string' ? raw.category : 'General',
tags: Array.isArray(raw.tags) ? raw.tags.filter((tag): tag is string => typeof tag === 'string') : [],
});
}
}
return summaries;
}
function readFrontmatterName(src: string): string | null {
const text = src.replace(/^\uFEFF/, '');
const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(text);
if (match == null) return null;
const nameMatch = /^name:\s*(.*?)\s*$/im.exec(match[1] ?? '');
if (nameMatch == null) return null;
const name = unquoteYamlScalar(nameMatch[1] ?? '').trim();
return name || null;
}
function unquoteYamlScalar(value: string): string {
const trimmed = value.trim();
if (
(trimmed.startsWith('"') && trimmed.endsWith('"')) ||
(trimmed.startsWith("'") && trimmed.endsWith("'"))
) {
return trimmed.slice(1, -1);
}
return trimmed;
}
describe('localized display content coverage', () => {
for (const [locale, ids] of Object.entries(LOCALIZED_CONTENT_IDS)) {
it(`covers every curated skill, design system, and prompt template for ${locale}`, async () => {
const [skillIds, designSystemIds, promptTemplateSummaries] = await Promise.all([
readSkillIds(),
readDesignSystemIds(),
readPromptTemplateSummaries(),
]);
expectExactResourceCoverage('skills display copy', ids.skills, skillIds);
expectExactResourceCoverage(
'design-system summaries',
ids.designSystems,
designSystemIds,
);
expectExactResourceCoverage(
'prompt-template metadata',
ids.promptTemplates,
uniqueSorted(promptTemplateSummaries.map((template) => template.id)),
);
});
it(`covers every curated display category and prompt tag for ${locale}`, async () => {
const [designSystemCategories, promptTemplateSummaries] = await Promise.all([
readDesignSystemCategories(),
readPromptTemplateSummaries(),
]);
const promptTemplateCategories = new Set(
promptTemplateSummaries.map((template) => template.category),
);
const promptTemplateTags = new Set(
promptTemplateSummaries.flatMap((template) => template.tags),
);
expect(sorted(ids.designSystemCategories)).toEqual(
expect.arrayContaining(designSystemCategories),
);
expect(sorted(ids.promptTemplateCategories)).toEqual(
expect.arrayContaining(sorted(promptTemplateCategories)),
);
expect(sorted(ids.promptTemplateTags)).toEqual(
expect.arrayContaining(sorted(promptTemplateTags)),
);
});
}
});