Skip to content

Commit 55afbf7

Browse files
committed
Add manifest cross-validation against locale registrations
The `manifest --cross-validate` flag checks all three registration-drift surfaces: the locale enum in command-api.yaml must match the manifest, settingsValidator.ts must use dynamic locale discovery via availableLocales, and settingsValidator.spec.ts test values must exist in the manifest. Signed-off-by: Jan Dubois <jan.dubois@suse.com>
1 parent ac69411 commit 55afbf7

2 files changed

Lines changed: 328 additions & 0 deletions

File tree

src/go/i18n-report/manifest_test.go

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package main
22

33
import (
4+
"fmt"
45
"os"
56
"path/filepath"
67
"strings"
@@ -123,6 +124,152 @@ locales:
123124
}
124125
}
125126

127+
// writeCrossValidationFiles writes the supporting files needed by
128+
// crossValidateManifest into a test repo directory.
129+
func writeCrossValidationFiles(t *testing.T, dir string, apiEnum string, validatorDynamic bool, specLocales string) {
130+
t.Helper()
131+
132+
// command-api.yaml
133+
specsDir := filepath.Join(dir, "pkg", "rancher-desktop", "assets", "specs")
134+
os.MkdirAll(specsDir, 0755)
135+
apiYAML := fmt.Sprintf(`paths:
136+
/v1/settings:
137+
get:
138+
responses:
139+
'200':
140+
content:
141+
application/json:
142+
schema:
143+
properties:
144+
application:
145+
properties:
146+
locale:
147+
type: string
148+
enum: [%s]
149+
`, apiEnum)
150+
os.WriteFile(filepath.Join(specsDir, "command-api.yaml"), []byte(apiYAML), 0644)
151+
152+
// settingsValidator.ts
153+
validatorDir := filepath.Join(dir, "pkg", "rancher-desktop", "main", "commandServer")
154+
os.MkdirAll(validatorDir, 0755)
155+
validatorContent := "locale: this.checkEnum('none', 'de'),\n"
156+
if validatorDynamic {
157+
validatorContent = "locale: this.checkEnum('none', ...availableLocales),\n"
158+
}
159+
os.WriteFile(filepath.Join(validatorDir, "settingsValidator.ts"), []byte(validatorContent), 0644)
160+
161+
// settingsValidator.spec.ts
162+
testDir := filepath.Join(validatorDir, "__tests__")
163+
os.MkdirAll(testDir, 0755)
164+
specContent := fmt.Sprintf(`describe('application.locale', () => {
165+
it('should accept valid locales', () => {
166+
%s
167+
});
168+
it('should reject invalid values', () => {
169+
{ application: { locale: 'invalid' } }
170+
});
171+
});
172+
`, specLocales)
173+
os.WriteFile(filepath.Join(testDir, "settingsValidator.spec.ts"), []byte(specContent), 0644)
174+
}
175+
176+
func TestCrossValidateManifestMatch(t *testing.T) {
177+
dir := setupManifestTestRepo(t, `
178+
locales:
179+
en-us:
180+
status: source
181+
de:
182+
status: experimental
183+
`, []string{"de.yaml"})
184+
185+
writeCrossValidationFiles(t, dir, "none, de, en-us", true,
186+
"{ application: { locale: 'en-us' } }, { application: { locale: 'de' } }")
187+
188+
m, err := loadManifest(dir)
189+
if err != nil {
190+
t.Fatal(err)
191+
}
192+
193+
err = crossValidateManifest(dir, m)
194+
if err != nil {
195+
t.Errorf("cross-validation should pass: %v", err)
196+
}
197+
}
198+
199+
func TestCrossValidateManifestMismatch(t *testing.T) {
200+
dir := setupManifestTestRepo(t, `
201+
locales:
202+
en-us:
203+
status: source
204+
de:
205+
status: experimental
206+
fa:
207+
status: experimental
208+
`, []string{"de.yaml", "fa.yaml"})
209+
210+
// API is missing "fa".
211+
writeCrossValidationFiles(t, dir, "none, de, en-us", true,
212+
"{ application: { locale: 'de' } }")
213+
214+
m, err := loadManifest(dir)
215+
if err != nil {
216+
t.Fatal(err)
217+
}
218+
219+
err = crossValidateManifest(dir, m)
220+
if err == nil {
221+
t.Error("cross-validation should fail when API is missing a locale")
222+
}
223+
}
224+
225+
func TestCrossValidateHardcodedValidator(t *testing.T) {
226+
dir := setupManifestTestRepo(t, `
227+
locales:
228+
en-us:
229+
status: source
230+
de:
231+
status: experimental
232+
`, []string{"de.yaml"})
233+
234+
// settingsValidator.ts uses hardcoded list instead of ...availableLocales.
235+
writeCrossValidationFiles(t, dir, "none, de, en-us", false,
236+
"{ application: { locale: 'de' } }")
237+
238+
m, err := loadManifest(dir)
239+
if err != nil {
240+
t.Fatal(err)
241+
}
242+
243+
err = crossValidateManifest(dir, m)
244+
if err == nil {
245+
t.Error("cross-validation should fail when settingsValidator.ts uses hardcoded locales")
246+
}
247+
}
248+
249+
func TestCrossValidateSpecUnknownLocale(t *testing.T) {
250+
dir := setupManifestTestRepo(t, `
251+
locales:
252+
en-us:
253+
status: source
254+
de:
255+
status: experimental
256+
`, []string{"de.yaml"})
257+
258+
// Spec references 'fr' which is not in the manifest.
259+
writeCrossValidationFiles(t, dir, "none, de, en-us", true,
260+
"{ application: { locale: 'fr' } }")
261+
262+
m, err := loadManifest(dir)
263+
if err != nil {
264+
t.Fatal(err)
265+
}
266+
267+
err = crossValidateManifest(dir, m)
268+
if err == nil {
269+
t.Error("cross-validation should fail when spec uses locale not in manifest")
270+
}
271+
}
272+
126273
func TestManifestMissingLocaleFile(t *testing.T) {
127274
// de.yaml not created — should fail validation.
128275
dir := setupManifestTestRepo(t, `

src/go/i18n-report/report_manifest.go

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,22 @@
11
package main
22

33
import (
4+
"flag"
45
"fmt"
6+
"os"
7+
"path/filepath"
8+
"regexp"
9+
"sort"
10+
"strings"
11+
12+
"gopkg.in/yaml.v3"
513
)
614

715
func runManifest(args []string) error {
16+
fs := flag.NewFlagSet("manifest", flag.ExitOnError)
17+
crossValidate := fs.Bool("cross-validate", false, "Also check locale registrations in command-api.yaml")
18+
fs.Parse(args)
19+
820
root, err := repoRoot()
921
if err != nil {
1022
return err
@@ -21,5 +33,174 @@ func runManifest(args []string) error {
2133
fmt.Printf(" %-12s %s\n", loc.Code, loc.Status)
2234
}
2335
fmt.Println("Manifest valid.")
36+
37+
if *crossValidate {
38+
return crossValidateManifest(root, m)
39+
}
2440
return nil
2541
}
42+
43+
// crossValidateManifest checks that the manifest's locale list matches
44+
// the locale enum in command-api.yaml.
45+
func crossValidateManifest(root string, m *Manifest) error {
46+
apiPath := translationsPath(root, "../specs/command-api.yaml")
47+
apiLocales, err := parseAPILocaleEnum(apiPath)
48+
if err != nil {
49+
return err
50+
}
51+
52+
// Build expected set: "none" plus all manifest locales.
53+
expected := make(map[string]bool)
54+
expected["none"] = true
55+
for code := range m.Locales {
56+
expected[code] = true
57+
}
58+
59+
apiSet := make(map[string]bool)
60+
for _, code := range apiLocales {
61+
apiSet[code] = true
62+
}
63+
64+
var errors []string
65+
66+
// Locales in manifest but missing from API.
67+
for code := range expected {
68+
if !apiSet[code] {
69+
errors = append(errors, fmt.Sprintf(" manifest locale %q missing from command-api.yaml enum", code))
70+
}
71+
}
72+
73+
// Locales in API but not in manifest (excluding "none").
74+
for code := range apiSet {
75+
if !expected[code] {
76+
errors = append(errors, fmt.Sprintf(" command-api.yaml enum has %q not in manifest", code))
77+
}
78+
}
79+
80+
// Validate settingsValidator.ts uses dynamic locale discovery.
81+
validatorPath := filepath.Join(root, "pkg", "rancher-desktop", "main",
82+
"commandServer", "settingsValidator.ts")
83+
if validatorData, err := os.ReadFile(validatorPath); err != nil {
84+
errors = append(errors, fmt.Sprintf(" cannot read settingsValidator.ts: %v", err))
85+
} else {
86+
content := string(validatorData)
87+
if !strings.Contains(content, "...availableLocales") {
88+
errors = append(errors, " settingsValidator.ts: locale checkEnum does not use ...availableLocales (hardcoded list?)")
89+
}
90+
}
91+
92+
// Validate settingsValidator.spec.ts test values against manifest.
93+
specPath := filepath.Join(root, "pkg", "rancher-desktop", "main",
94+
"commandServer", "__tests__", "settingsValidator.spec.ts")
95+
if specData, err := os.ReadFile(specPath); err != nil {
96+
errors = append(errors, fmt.Sprintf(" cannot read settingsValidator.spec.ts: %v", err))
97+
} else {
98+
specErrors := crossValidateSpec(string(specData), expected)
99+
errors = append(errors, specErrors...)
100+
}
101+
102+
sort.Strings(errors)
103+
104+
if len(errors) > 0 {
105+
fmt.Println("\nCross-validation errors:")
106+
for _, e := range errors {
107+
fmt.Println(e)
108+
}
109+
return fmt.Errorf("cross-validation failed")
110+
}
111+
112+
fmt.Println("Cross-validation passed.")
113+
return nil
114+
}
115+
116+
// parseAPILocaleEnum extracts the locale enum values from command-api.yaml
117+
// by parsing the YAML structure instead of using fragile regex matching.
118+
func parseAPILocaleEnum(path string) ([]string, error) {
119+
data, err := os.ReadFile(path)
120+
if err != nil {
121+
return nil, fmt.Errorf("reading command-api.yaml: %w", err)
122+
}
123+
124+
// Parse into a generic structure and navigate to the locale enum.
125+
var doc map[string]interface{}
126+
if err := yaml.Unmarshal(data, &doc); err != nil {
127+
return nil, fmt.Errorf("parsing command-api.yaml: %w", err)
128+
}
129+
130+
// Navigate: paths.*.*.properties.application.properties.locale.enum
131+
// The locale field is nested under application settings; find it by
132+
// walking all schema definitions looking for an "application" property
133+
// with a "locale" child that has an enum.
134+
locales := findLocaleEnum(doc)
135+
if locales == nil {
136+
return nil, fmt.Errorf("could not find locale enum in command-api.yaml")
137+
}
138+
return locales, nil
139+
}
140+
141+
// findLocaleEnum searches a parsed YAML structure for a "locale" property
142+
// definition that contains an "enum" list, returning the enum values.
143+
func findLocaleEnum(v interface{}) []string {
144+
switch node := v.(type) {
145+
case map[string]interface{}:
146+
// If this map has a "locale" key whose value has an "enum", that's it.
147+
if locale, found := node["locale"]; found {
148+
if localeMap, ok := locale.(map[string]interface{}); ok {
149+
if enumVal, hasEnum := localeMap["enum"]; hasEnum {
150+
if items, ok := enumVal.([]interface{}); ok {
151+
var result []string
152+
for _, item := range items {
153+
if s, ok := item.(string); ok {
154+
result = append(result, s)
155+
}
156+
}
157+
if len(result) > 0 {
158+
return result
159+
}
160+
}
161+
}
162+
}
163+
}
164+
// Recurse into all map values.
165+
for _, val := range node {
166+
if result := findLocaleEnum(val); result != nil {
167+
return result
168+
}
169+
}
170+
case []interface{}:
171+
for _, val := range node {
172+
if result := findLocaleEnum(val); result != nil {
173+
return result
174+
}
175+
}
176+
}
177+
return nil
178+
}
179+
180+
// specLocaleRe matches locale string values in test assertions, e.g.:
181+
//
182+
// { application: { locale: 'de' } }
183+
var specLocaleRe = regexp.MustCompile(`locale:\s*'([a-z][\w-]*)'`)
184+
185+
// crossValidateSpec checks that locale values used as valid inputs in
186+
// the settings validator spec are registered in the manifest.
187+
func crossValidateSpec(specContent string, manifestLocales map[string]bool) []string {
188+
matches := specLocaleRe.FindAllStringSubmatch(specContent, -1)
189+
seen := make(map[string]bool)
190+
var errors []string
191+
for _, m := range matches {
192+
code := m[1]
193+
if seen[code] {
194+
continue
195+
}
196+
seen[code] = true
197+
// "none" and "invalid" are special test values, not real locales.
198+
if code == "none" || code == "invalid" {
199+
continue
200+
}
201+
if !manifestLocales[code] {
202+
errors = append(errors, fmt.Sprintf(" settingsValidator.spec.ts uses locale %q not in manifest", code))
203+
}
204+
}
205+
return errors
206+
}

0 commit comments

Comments
 (0)