Some checks failed
Server CI / Postgres (shard 0) (push) Has been skipped
Server CI / Postgres (shard 3) (push) Has been skipped
BuildEnv Docker Image / build-image (push) Failing after 4s
BuildEnv Docker Image / build-image-fips (push) Failing after 4s
Migration Review & Release Notes / Generate migration review & release notes (push) Failing after 3s
Server CI / Build Environment (push) Failing after 3s
Server CI / Postgres (shard 1) (push) Has been skipped
Tools CI / check-style (mattermost-govet) (push) Failing after 4s
Server CI / Postgres (shard 2) (push) Has been skipped
Tools CI / check-style (mmgotool) (push) Failing after 3s
Server CI / Elasticsearch v8 Compatibility (push) Has been skipped
Server CI / OpenSearch v2 Compatibility (push) Has been skipped
Server CI / Postgres FIPS (shard 0) (push) Has been skipped
Tools CI / Test (mattermost-govet) (push) Failing after 3s
Server CI / Postgres FIPS (shard 1) (push) Has been skipped
Server CI / Postgres FIPS (shard 2) (push) Has been skipped
Tools CI / Test (mmgotool) (push) Failing after 3s
Server CI / Postgres FIPS (shard 3) (push) Has been skipped
Server CI / Run mmctl tests (push) Has been skipped
Server CI / Run mmctl tests (FIPS) (push) Has been skipped
Server CI / Merge Postgres Test Results (push) Has been skipped
Server CI / Merge Postgres FIPS Test Results (push) Has been skipped
Server CI / Check backport migrations (push) Has been skipped
Server CI / Check generated files (push) Has been skipped
Server CI / check-style (push) Has been skipped
Server CI / Vet API (push) Has been skipped
Server CI / Build mattermost server app (push) Has been skipped
Server CI / Server CI Complete (push) Failing after 2s
Server CI / Server CI Artifacts (push) Has been skipped
Server CI / Sentry Upload (push) Has been skipped
Server CI / Server CI Report (push) Failing after 5m1s
Server CI Nightly Race / Race Detector (push) Has been skipped
CodeQL / Analyze (go) (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
Webapp CI Cache Warm / Warm Webapp node_modules Cache (push) Has been cancelled
E2E CI Cache Warm / Warm E2E npm Registry Cache (push) Has been cancelled
API / build (push) Has been cancelled
Web App CI / check-types (push) Has been cancelled
Web App CI / check-lint (push) Has been cancelled
Web App CI / check-i18n (push) Has been cancelled
Web App CI / check-external-links (push) Has been cancelled
Web App CI / test (platform) (push) Has been cancelled
Web App CI / test (mattermost-redux) (push) Has been cancelled
Web App CI / test (channels shard 1/4) (push) Has been cancelled
Web App CI / test (channels shard 2/4) (push) Has been cancelled
Web App CI / test (channels shard 3/4) (push) Has been cancelled
Web App CI / test (channels shard 4/4) (push) Has been cancelled
Web App CI / upload-coverage (push) Has been cancelled
Web App CI / build (push) Has been cancelled
YAML Lint / yamllint (push) Has been cancelled
Push mirrored docker images / cd/Push mirrored docker images (push) Has been cancelled
docs-cd / Build and deploy docs (push) Has been cancelled
Docs CI / build (push) Has been cancelled
E2E Cache Warm: Cypress deps / Warm Cypress deps cache (push) Has been cancelled
Scorecards supply-chain security / Scorecard analysis (push) Has been cancelled
Server CI Weekly / Postgres FIPS (push) Has been skipped
Server CI Weekly / Run mmctl tests (FIPS) (push) Has been skipped
Server CI Weekly / Postgres with binary parameters (push) Has been skipped
Server CI Weekly / Build Environment (push) Failing after 1s
Server CI Cache Warm / Warm Go Module and Build Cache (push) Failing after 2s
Server CI Nightly Race / Build Environment (push) Failing after 1s
345 lines
12 KiB
Go
345 lines
12 KiB
Go
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
|
|
// See License.txt for license information.
|
|
|
|
package commands
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// writeCatalog writes one locale file into dir and returns its path.
|
|
func writeCatalog(t *testing.T, dir, name, body string) string {
|
|
t.Helper()
|
|
|
|
p := filepath.Join(dir, name)
|
|
require.NoError(t, os.WriteFile(p, []byte(body), 0600))
|
|
|
|
return p
|
|
}
|
|
|
|
// source parses an en.json body into the items a catalog is checked against.
|
|
func source(t *testing.T, en string) map[string]Item {
|
|
t.Helper()
|
|
|
|
items, err := loadItems([]byte(en))
|
|
require.NoError(t, err)
|
|
|
|
return items
|
|
}
|
|
|
|
func TestVerifyLocale(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// A pluralized source and a plain source, so each case can pick the shape
|
|
// it needs without restating the catalog.
|
|
const enPlural = `[{"id":"a.b","translation":{"one":"{{.User}} is here","other":"{{.User}} are here"}}]`
|
|
const enPlain = `[{"id":"a.b","translation":"{{.User}} is here"}]`
|
|
|
|
testCases := []struct {
|
|
name string
|
|
localeName string
|
|
en string
|
|
locale string
|
|
warnMissingIDs bool
|
|
problem string // substring expected in problems, "" for none
|
|
warning string // substring expected in warnings, "" for none
|
|
}{
|
|
{
|
|
name: "clean plain catalog",
|
|
localeName: "fr.json",
|
|
en: enPlain,
|
|
locale: `[{"id":"a.b","translation":"{{.User}} est ici"}]`,
|
|
},
|
|
{
|
|
name: "clean pluralized catalog",
|
|
localeName: "fr.json",
|
|
en: enPlural,
|
|
locale: `[{"id":"a.b","translation":{"one":"{{.User}} est ici","other":"{{.User}} sont ici"}}]`,
|
|
},
|
|
|
|
// The runtime loader is the first gate: whatever it rejects at server
|
|
// startup has to be rejected here.
|
|
{
|
|
name: "loader rejects invalid JSON",
|
|
localeName: "fr.json",
|
|
en: enPlain,
|
|
locale: `[{"id":"a.b",`,
|
|
problem: "rejected by the runtime translation loader",
|
|
},
|
|
{
|
|
name: "loader rejects an unparseable template",
|
|
localeName: "fr.json",
|
|
en: enPlain,
|
|
locale: `[{"id":"a.b","translation":"{{.User est ici"}]`,
|
|
problem: "rejected by the runtime translation loader",
|
|
},
|
|
|
|
// Id parity.
|
|
{
|
|
name: "missing id is an error by default",
|
|
localeName: "fr.json",
|
|
en: enPlain,
|
|
locale: `[]`,
|
|
problem: "a.b: missing id",
|
|
},
|
|
{
|
|
name: "missing id is a warning under warn-missing-ids",
|
|
localeName: "fr.json",
|
|
en: enPlain,
|
|
locale: `[]`,
|
|
warnMissingIDs: true,
|
|
warning: "a.b: missing id",
|
|
},
|
|
{
|
|
name: "extra id not in en.json",
|
|
localeName: "fr.json",
|
|
en: enPlain,
|
|
locale: `[{"id":"a.b","translation":"{{.User}} est ici"},{"id":"z.z","translation":"orphelin"}]`,
|
|
problem: "z.z: extra id not in en.json",
|
|
},
|
|
|
|
// Template tokens. An unknown one renders "<no value>"; a dropped one
|
|
// quietly loses the value it was meant to show.
|
|
{
|
|
name: "unknown template token",
|
|
localeName: "fr.json",
|
|
en: enPlain,
|
|
locale: `[{"id":"a.b","translation":"{{.User}} est ici, {{.Extra}}"}]`,
|
|
problem: `unknown template token {{.Extra}} not present in source`,
|
|
},
|
|
{
|
|
name: "dropped template token",
|
|
localeName: "fr.json",
|
|
en: enPlain,
|
|
locale: `[{"id":"a.b","translation":"quelqu'un est ici"}]`,
|
|
problem: `source template token {{.User}} is missing from the translation`,
|
|
},
|
|
{
|
|
name: "a token in any plural form counts as present",
|
|
localeName: "fr.json",
|
|
en: enPlural,
|
|
locale: `[{"id":"a.b","translation":{"one":"{{.User}} est ici","other":"ils sont ici"}}]`,
|
|
},
|
|
|
|
// Plural shape against en.json.
|
|
{
|
|
name: "demoting a pluralized source to a single string",
|
|
localeName: "fr.json",
|
|
en: enPlural,
|
|
locale: `[{"id":"a.b","translation":"{{.User}} sont ici"}]`,
|
|
problem: "en.json pluralises this id but the translation is a single string",
|
|
},
|
|
{
|
|
name: "promoting a plain source to a plural is allowed",
|
|
localeName: "fr.json",
|
|
en: enPlain,
|
|
locale: `[{"id":"a.b","translation":{"one":"{{.User}} est ici","other":"{{.User}} sont ici"}}]`,
|
|
},
|
|
{
|
|
name: "categories are checked on a promoted plural too",
|
|
localeName: "fr.json",
|
|
en: enPlain,
|
|
locale: `[{"id":"a.b","translation":{"one":"{{.User}} est ici","many":"x","other":"{{.User}} sont ici"}}]`,
|
|
problem: `plural category "many" is not used by this locale`,
|
|
},
|
|
|
|
// CLDR categories for the locale.
|
|
{
|
|
name: "missing a category the locale requires",
|
|
localeName: "ro.json",
|
|
en: enPlural,
|
|
locale: `[{"id":"a.b","translation":{"one":"{{.User}} e aici","other":"{{.User}} sunt aici"}}]`,
|
|
problem: `missing plural category "few"`,
|
|
},
|
|
{
|
|
name: "a category the locale does not use",
|
|
localeName: "ja.json",
|
|
en: enPlural,
|
|
locale: `[{"id":"a.b","translation":{"one":"{{.User}}","other":"{{.User}}"}}]`,
|
|
problem: `plural category "one" is not used by this locale`,
|
|
},
|
|
|
|
// An empty form renders the raw translation id, so it is worse than no
|
|
// translation at all.
|
|
{
|
|
name: "empty plural form",
|
|
localeName: "fr.json",
|
|
en: enPlural,
|
|
locale: `[{"id":"a.b","translation":{"one":"{{.User}} est ici","other":""}}]`,
|
|
problem: `plural category "other" is empty, which renders the raw translation id`,
|
|
},
|
|
{
|
|
name: "whitespace-only plural form",
|
|
localeName: "fr.json",
|
|
en: enPlural,
|
|
locale: `[{"id":"a.b","translation":{"one":"{{.User}} est ici","other":" "}}]`,
|
|
problem: `plural category "other" is empty, which renders the raw translation id`,
|
|
},
|
|
}
|
|
|
|
for _, tc := range testCases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
problems, warnings := verifyLocale(tc.localeName, []byte(tc.locale), source(t, tc.en), tc.warnMissingIDs)
|
|
|
|
if tc.problem == "" {
|
|
assert.Empty(t, problems, "expected no problems")
|
|
} else {
|
|
assert.Contains(t, strings.Join(problems, "\n"), tc.problem)
|
|
}
|
|
|
|
if tc.warning == "" {
|
|
assert.Empty(t, warnings, "expected no warnings")
|
|
} else {
|
|
assert.Contains(t, strings.Join(warnings, "\n"), tc.warning)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// The runtime loader rejects these before any of the catalog checks run, which
|
|
// is what makes the "no CLDR plural spec" branch unreachable: it derives the
|
|
// language with language.Parse, which only yields one when a plural spec exists
|
|
// for the same tag.
|
|
func TestVerifyLocaleUnknownLocale(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const catalog = `[{"id":"a.b","translation":"hi"}]`
|
|
|
|
for _, name := range []string{"xx.json", "notes.json", "README.json"} {
|
|
t.Run(name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
problems, _ := verifyLocale(name, []byte(catalog), source(t, catalog), false)
|
|
assert.Contains(t, strings.Join(problems, "\n"), "rejected by the runtime translation loader")
|
|
})
|
|
}
|
|
}
|
|
|
|
// Each file is loaded into its own bundle, so a defect in one locale can never
|
|
// be masked by another having already been loaded, in either order.
|
|
func TestVerifyLocaleIsOrderIndependent(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const en = `[{"id":"a.b","translation":{"one":"{{.User}} is here","other":"{{.User}} are here"}}]`
|
|
const good = `[{"id":"a.b","translation":{"one":"{{.User}} ist da","other":"{{.User}} sind da"}}]`
|
|
const bad = `[{"id":"a.b","translation":{"one":"{{.User}} est ici","other":""}}]`
|
|
|
|
items := source(t, en)
|
|
|
|
badFirst, _ := verifyLocale("fr.json", []byte(bad), items, false)
|
|
_, _ = verifyLocale("de.json", []byte(good), items, false)
|
|
badSecond, _ := verifyLocale("fr.json", []byte(bad), items, false)
|
|
|
|
assert.NotEmpty(t, badFirst)
|
|
assert.Equal(t, badFirst, badSecond, "the same file must report the same problems regardless of what was checked before it")
|
|
}
|
|
|
|
func TestVerifyCmd(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// setup builds a server-dir whose i18n/ holds en.json, one locale, and the
|
|
// entries the walk is meant to ignore.
|
|
setup := func(t *testing.T, locale string) string {
|
|
t.Helper()
|
|
|
|
serverDir := t.TempDir()
|
|
dir := filepath.Join(serverDir, "i18n")
|
|
require.NoError(t, os.MkdirAll(dir, 0700))
|
|
writeCatalog(t, dir, "en.json", `[{"id":"a.b","translation":"{{.User}} is here"}]`)
|
|
writeCatalog(t, dir, "fr.json", locale)
|
|
|
|
// Neither of these is a locale catalog, and neither may be walked.
|
|
writeCatalog(t, dir, "README.md", "not a catalog")
|
|
require.NoError(t, os.MkdirAll(filepath.Join(dir, "nested"), 0700))
|
|
writeCatalog(t, filepath.Join(dir, "nested"), "de.json", `[{"id":"nope","translation":"x"}]`)
|
|
|
|
return serverDir
|
|
}
|
|
|
|
run := func(t *testing.T, serverDir string, args ...string) error {
|
|
t.Helper()
|
|
|
|
cmd := *VerifyCmd
|
|
cmd.SetArgs(args)
|
|
cmd.SetOut(os.NewFile(0, os.DevNull))
|
|
return verifyCmdF(&cmd, nil)
|
|
}
|
|
|
|
t.Run("a clean catalog passes, and non-catalog entries are skipped", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
serverDir := setup(t, `[{"id":"a.b","translation":"{{.User}} est ici"}]`)
|
|
require.NoError(t, VerifyCmd.Flags().Set("server-dir", serverDir))
|
|
require.NoError(t, VerifyCmd.Flags().Set("warn-missing-ids", "false"))
|
|
|
|
// A walked README.md or nested/de.json would surface as an extra id.
|
|
assert.NoError(t, run(t, serverDir))
|
|
})
|
|
|
|
t.Run("a defect fails the command", func(t *testing.T) {
|
|
serverDir := setup(t, `[{"id":"a.b","translation":"{{.Nope}} est ici"}]`)
|
|
require.NoError(t, VerifyCmd.Flags().Set("server-dir", serverDir))
|
|
require.NoError(t, VerifyCmd.Flags().Set("warn-missing-ids", "false"))
|
|
|
|
err := run(t, serverDir)
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), "error(s) across 1 locale files")
|
|
})
|
|
|
|
t.Run("a stray json file is reported", func(t *testing.T) {
|
|
serverDir := setup(t, `[{"id":"a.b","translation":"{{.User}} est ici"}]`)
|
|
writeCatalog(t, filepath.Join(serverDir, "i18n"), "notes.json", "{not even json")
|
|
require.NoError(t, VerifyCmd.Flags().Set("server-dir", serverDir))
|
|
require.NoError(t, VerifyCmd.Flags().Set("warn-missing-ids", "false"))
|
|
|
|
err := run(t, serverDir)
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), "error(s) across 2 locale files")
|
|
})
|
|
|
|
t.Run("a source catalog the loader rejects fails the command", func(t *testing.T) {
|
|
serverDir := t.TempDir()
|
|
dir := filepath.Join(serverDir, "i18n")
|
|
require.NoError(t, os.MkdirAll(dir, 0700))
|
|
writeCatalog(t, dir, "en.json", `[{"id":"a.b","translation":"{{.User is here"}]`)
|
|
writeCatalog(t, dir, "fr.json", `[{"id":"a.b","translation":"{{.User}} est ici"}]`)
|
|
require.NoError(t, VerifyCmd.Flags().Set("server-dir", serverDir))
|
|
require.NoError(t, VerifyCmd.Flags().Set("warn-missing-ids", "false"))
|
|
|
|
err := run(t, serverDir)
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), "rejected by the runtime translation loader")
|
|
})
|
|
|
|
// The symlink target is a loadable catalog, so root is the only thing that
|
|
// can fail this.
|
|
t.Run("a catalog symlinked out of the translation directory is refused", func(t *testing.T) {
|
|
serverDir := setup(t, `[{"id":"a.b","translation":"{{.User}} est ici"}]`)
|
|
outside := writeCatalog(t, t.TempDir(), "de.json", `[{"id":"a.b","translation":"{{.User}} ist da"}]`)
|
|
require.NoError(t, os.Symlink(outside, filepath.Join(serverDir, "i18n", "de.json")))
|
|
require.NoError(t, VerifyCmd.Flags().Set("server-dir", serverDir))
|
|
require.NoError(t, VerifyCmd.Flags().Set("warn-missing-ids", "false"))
|
|
|
|
err := run(t, serverDir)
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), "error(s) across 2 locale files")
|
|
})
|
|
|
|
t.Run("a missing translation directory is reported against the path", func(t *testing.T) {
|
|
require.NoError(t, VerifyCmd.Flags().Set("server-dir", filepath.Join(t.TempDir(), "nope")))
|
|
require.NoError(t, VerifyCmd.Flags().Set("warn-missing-ids", "false"))
|
|
|
|
err := run(t, "")
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), "failed to open translation directory")
|
|
})
|
|
}
|