@jskit-ai/users-web 0.2.12 → 0.2.14
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/fixtures/translated-app/PreferencesSection.vue +29 -0
- package/fixtures/translated-app/appMessages.json +13 -0
- package/fixtures/translated-app/index.html +12 -0
- package/fixtures/translated-app/main.js +181 -0
- package/fixtures/translated-app/src/error.js +2 -0
- package/fixtures/translated-app/src/placement.js +2 -0
- package/fixtures/translated-app/src/placementTopology.js +2 -0
- package/fixtures/translated-app/vite.config.mjs +14 -0
- package/package.json +8 -8
- package/test/translatedApp.browser.test.js +405 -0
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { useI18n } from "vue-i18n";
|
|
3
|
+
|
|
4
|
+
// An app's own settings section: its words come from the app's messages, its
|
|
5
|
+
// choices, feedback and field errors from JSKIT.
|
|
6
|
+
const props = defineProps({
|
|
7
|
+
runtime: {
|
|
8
|
+
type: Object,
|
|
9
|
+
required: true
|
|
10
|
+
}
|
|
11
|
+
});
|
|
12
|
+
const { t } = useI18n();
|
|
13
|
+
const preferences = props.runtime.preferences;
|
|
14
|
+
</script>
|
|
15
|
+
|
|
16
|
+
<template>
|
|
17
|
+
<v-form novalidate @submit.prevent="preferences.submit">
|
|
18
|
+
<v-select
|
|
19
|
+
v-model="preferences.form.theme"
|
|
20
|
+
:items="preferences.options.theme"
|
|
21
|
+
:label="t('app.settings.theme')"
|
|
22
|
+
:error-messages="preferences.fieldErrors.theme"
|
|
23
|
+
data-testid="theme-select"
|
|
24
|
+
/>
|
|
25
|
+
<v-btn type="submit" data-testid="preferences-save" :loading="preferences.isSaving.value">
|
|
26
|
+
{{ t("app.settings.save") }}
|
|
27
|
+
</v-btn>
|
|
28
|
+
</v-form>
|
|
29
|
+
</template>
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>Translated app acceptance</title>
|
|
7
|
+
</head>
|
|
8
|
+
<body>
|
|
9
|
+
<div id="app"></div>
|
|
10
|
+
<script type="module" src="/main.js"></script>
|
|
11
|
+
</body>
|
|
12
|
+
</html>
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { createApp, h, inject, onMounted, reactive, ref, shallowReactive } from "vue";
|
|
2
|
+
import { createPinia } from "pinia";
|
|
3
|
+
import { createI18n } from "vue-i18n";
|
|
4
|
+
import { createRouter, createWebHistory, RouterView } from "vue-router";
|
|
5
|
+
import { QueryClient, VueQueryPlugin, useQuery } from "@tanstack/vue-query";
|
|
6
|
+
import "vuetify/styles";
|
|
7
|
+
import { createVuetify } from "vuetify";
|
|
8
|
+
import * as components from "vuetify/components";
|
|
9
|
+
import * as directives from "vuetify/directives";
|
|
10
|
+
import { aliases, mdi } from "vuetify/iconsets/mdi-svg";
|
|
11
|
+
import { registerJskitMessages, setJskitI18n } from "@jskit-ai/kernel/client/i18n";
|
|
12
|
+
import { silentLogger } from "@jskit-ai/kernel/shared/support/silentLogger";
|
|
13
|
+
import { createHttpClient } from "@jskit-ai/http-runtime/client";
|
|
14
|
+
import { ShellErrorHost, ShellWebClientProvider, useShellErrorPresentationStore } from "@jskit-ai/shell-web/client";
|
|
15
|
+
import { SHELL_WEB_ERROR_RUNTIME_KEY } from "@jskit-ai/shell-web/client/error";
|
|
16
|
+
import { SHELL_WEB_PLACEMENT_RUNTIME_KEY } from "@jskit-ai/shell-web/client/placement";
|
|
17
|
+
import { SHELL_REQUEST_RECOVERY_RUNTIME_KEY } from "@jskit-ai/shell-web/client/requestRecovery";
|
|
18
|
+
import {
|
|
19
|
+
AuthProfileMenuLinkItem,
|
|
20
|
+
AuthProfileWidget,
|
|
21
|
+
AuthWebClientProvider,
|
|
22
|
+
DefaultLoginView,
|
|
23
|
+
DefaultResetPasswordView,
|
|
24
|
+
DefaultSignOutView
|
|
25
|
+
} from "@jskit-ai/auth-web/client";
|
|
26
|
+
import AccountSettingsClientElement from "../../src/client/components/AccountSettingsClientElement.vue";
|
|
27
|
+
import ProfileClientElement from "../../src/client/components/ProfileClientElement.vue";
|
|
28
|
+
import appMessages from "./appMessages.json" with { type: "json" };
|
|
29
|
+
import PreferencesSection from "./PreferencesSection.vue";
|
|
30
|
+
|
|
31
|
+
// An app translated into the locale the test exported with `jskit i18n export`
|
|
32
|
+
// and handed to the page. Every JSKIT package's English is registered, as the
|
|
33
|
+
// JSKIT Vite plugin registers each installed package's catalog.
|
|
34
|
+
const translation = window.translatedFixture;
|
|
35
|
+
const catalogs = import.meta.glob("../../../*/locales/en.json", { eager: true, import: "default" });
|
|
36
|
+
Object.values(catalogs).forEach(registerJskitMessages);
|
|
37
|
+
const i18n = createI18n({
|
|
38
|
+
legacy: false,
|
|
39
|
+
locale: translation.locale,
|
|
40
|
+
fallbackLocale: "en",
|
|
41
|
+
messages: { en: appMessages, [translation.locale]: translation.messages },
|
|
42
|
+
missingWarn: false,
|
|
43
|
+
fallbackWarn: false,
|
|
44
|
+
warnHtmlMessage: false
|
|
45
|
+
});
|
|
46
|
+
setJskitI18n(i18n);
|
|
47
|
+
|
|
48
|
+
const registered = new Map();
|
|
49
|
+
const componentRegistry = {
|
|
50
|
+
register(id, component) {
|
|
51
|
+
registered.set(id, component);
|
|
52
|
+
return componentRegistry;
|
|
53
|
+
},
|
|
54
|
+
has: (id) => registered.has(id),
|
|
55
|
+
get: (id) => registered.get(id)
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
// The shell's placements as a double; the error runtime, its host and request
|
|
59
|
+
// recovery are the real ones ShellWebClientProvider builds.
|
|
60
|
+
let context = {
|
|
61
|
+
surfaceConfig: {
|
|
62
|
+
tenancyMode: "none",
|
|
63
|
+
defaultSurfaceId: "home",
|
|
64
|
+
enabledSurfaceIds: ["home", "auth", "account"],
|
|
65
|
+
surfacesById: {
|
|
66
|
+
home: { id: "home", enabled: true, pagesRoot: "home", routeBase: "/home", requiresWorkspace: false },
|
|
67
|
+
auth: { id: "auth", enabled: true, pagesRoot: "auth", routeBase: "/auth", requiresWorkspace: false },
|
|
68
|
+
account: { id: "account", enabled: true, pagesRoot: "account", routeBase: "/account", requiresWorkspace: false }
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
// What a host passes ProfileClientElement.
|
|
74
|
+
const profileState = shallowReactive({
|
|
75
|
+
profileForm: reactive({ displayName: "Ada", email: "ada@example.com" }),
|
|
76
|
+
preferencesForm: reactive({ avatarSize: 64 }),
|
|
77
|
+
profileAvatar: reactive({ effectiveUrl: "", hasUploadedAvatar: true }),
|
|
78
|
+
profileInitials: "AD",
|
|
79
|
+
selectedAvatarFileName: "",
|
|
80
|
+
profileFieldErrors: reactive({}),
|
|
81
|
+
profileMutation: { isPending: ref(false) },
|
|
82
|
+
avatarDeleteMutation: { isPending: ref(false) }
|
|
83
|
+
});
|
|
84
|
+
const profileActions = {
|
|
85
|
+
async submitProfile() {},
|
|
86
|
+
async openAvatarEditor() {},
|
|
87
|
+
async submitAvatarDelete() {}
|
|
88
|
+
};
|
|
89
|
+
const ProfileSection = { render: () => h(ProfileClientElement, { state: profileState, actions: profileActions }) };
|
|
90
|
+
|
|
91
|
+
function section(value, title, component, order) {
|
|
92
|
+
return { order, component, props: { value, title, usesSharedRuntime: true } };
|
|
93
|
+
}
|
|
94
|
+
const placementsByTarget = {
|
|
95
|
+
"account-settings:sections": [
|
|
96
|
+
section("profile", "jskit.users.accountSettings.profile.title", ProfileSection, 100),
|
|
97
|
+
section("preferences", "app.settings.preferences", PreferencesSection, 200)
|
|
98
|
+
],
|
|
99
|
+
"auth-profile-menu:primary-menu": [
|
|
100
|
+
{ id: "app.menu.settings", component: AuthProfileMenuLinkItem, props: { label: "app.menu.settings", to: "/account" } },
|
|
101
|
+
{ id: "app.menu.sign-out", component: AuthProfileMenuLinkItem, props: { label: "app.menu.signOut", to: "/auth/signout" } }
|
|
102
|
+
]
|
|
103
|
+
};
|
|
104
|
+
const placement = {
|
|
105
|
+
getContext: () => context,
|
|
106
|
+
getPlacements: ({ target } = {}) => placementsByTarget[target] || [],
|
|
107
|
+
getRevision: () => 0,
|
|
108
|
+
subscribe: () => () => {},
|
|
109
|
+
setContext(patch) {
|
|
110
|
+
context = { ...context, ...patch };
|
|
111
|
+
return context;
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const fixtureHttp = createHttpClient({ credentials: "include" });
|
|
116
|
+
// A page whose requests fail: a query the shell's request recovery reports as
|
|
117
|
+
// a banner, and a request the page reports itself as a snackbar (the shell
|
|
118
|
+
// shows one banner at a time).
|
|
119
|
+
const ErrorsPage = {
|
|
120
|
+
setup() {
|
|
121
|
+
const errorRuntime = inject(SHELL_WEB_ERROR_RUNTIME_KEY);
|
|
122
|
+
useQuery({
|
|
123
|
+
queryKey: ["fixture", "offline"],
|
|
124
|
+
queryFn: () => fixtureHttp.request("/api/offline"),
|
|
125
|
+
meta: { jskit: { requestRecoveryMethod: "GET" } }
|
|
126
|
+
});
|
|
127
|
+
onMounted(async () => {
|
|
128
|
+
try {
|
|
129
|
+
await fixtureHttp.request("/api/broken");
|
|
130
|
+
} catch (error) {
|
|
131
|
+
errorRuntime.report({ source: "fixture.errors", message: error.message, cause: error, severity: "error", channel: "snackbar" });
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
return () => h("div", { "data-testid": "errors-page" });
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
const HomePage = { render: () => h("div", { "data-testid": "home" }, [h(AuthProfileWidget)]) };
|
|
138
|
+
|
|
139
|
+
const router = createRouter({
|
|
140
|
+
history: createWebHistory(),
|
|
141
|
+
routes: [
|
|
142
|
+
{ path: "/home", component: HomePage },
|
|
143
|
+
{ path: "/errors", component: ErrorsPage },
|
|
144
|
+
{ path: "/account", component: AccountSettingsClientElement },
|
|
145
|
+
{ path: "/auth/login", component: DefaultLoginView },
|
|
146
|
+
{ path: "/auth/reset-password", component: DefaultResetPasswordView },
|
|
147
|
+
{ path: "/auth/signout", component: DefaultSignOutView }
|
|
148
|
+
]
|
|
149
|
+
});
|
|
150
|
+
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
151
|
+
const pinia = createPinia();
|
|
152
|
+
const app = createApp({
|
|
153
|
+
render: () => h(components.VApp, null, {
|
|
154
|
+
default: () => [h(components.VMain, null, { default: () => h(RouterView) }), h(ShellErrorHost)]
|
|
155
|
+
})
|
|
156
|
+
});
|
|
157
|
+
app.use(i18n);
|
|
158
|
+
app.use(pinia);
|
|
159
|
+
app.use(router);
|
|
160
|
+
app.use(VueQueryPlugin, { queryClient });
|
|
161
|
+
app.use(createVuetify({ components, directives, icons: { defaultSet: "mdi", aliases, sets: { mdi } } }));
|
|
162
|
+
|
|
163
|
+
const { shell } = ShellWebClientProvider.setup({ components: componentRegistry, logger: silentLogger, queryClient, router });
|
|
164
|
+
shell.error.configure({});
|
|
165
|
+
useShellErrorPresentationStore(pinia).attachRuntimeStore(shell.errorPresentationStore);
|
|
166
|
+
shell.requestRecovery.install();
|
|
167
|
+
app.provide(SHELL_WEB_PLACEMENT_RUNTIME_KEY, placement);
|
|
168
|
+
app.provide(SHELL_WEB_ERROR_RUNTIME_KEY, shell.error);
|
|
169
|
+
app.provide(SHELL_REQUEST_RECOVERY_RUNTIME_KEY, shell.requestRecovery);
|
|
170
|
+
|
|
171
|
+
const outputs = AuthWebClientProvider.setup({
|
|
172
|
+
components: componentRegistry,
|
|
173
|
+
mobile: null,
|
|
174
|
+
pinia,
|
|
175
|
+
realtime: null,
|
|
176
|
+
shell: { placement, bootstrap: { async refresh() {} } },
|
|
177
|
+
vueApp: app
|
|
178
|
+
});
|
|
179
|
+
await AuthWebClientProvider.boot({}, { outputs });
|
|
180
|
+
await router.isReady();
|
|
181
|
+
app.mount("#app");
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { fileURLToPath } from "node:url";
|
|
2
|
+
import { defineConfig } from "vite";
|
|
3
|
+
import vue from "@vitejs/plugin-vue";
|
|
4
|
+
|
|
5
|
+
export default defineConfig({
|
|
6
|
+
root: fileURLToPath(new URL("./", import.meta.url)),
|
|
7
|
+
plugins: [vue()],
|
|
8
|
+
define: {
|
|
9
|
+
__VUE_I18N_FULL_INSTALL__: true,
|
|
10
|
+
__VUE_I18N_LEGACY_API__: false,
|
|
11
|
+
__INTLIFY_PROD_DEVTOOLS__: false
|
|
12
|
+
},
|
|
13
|
+
server: { host: "127.0.0.1", strictPort: true }
|
|
14
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jskit-ai/users-web",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.14",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"test": "node --test --test-concurrency=1"
|
|
@@ -13,15 +13,15 @@
|
|
|
13
13
|
"./locales/en.json": "./locales/en.json"
|
|
14
14
|
},
|
|
15
15
|
"dependencies": {
|
|
16
|
-
"@jskit-ai/uploads-image-web": "0.2.
|
|
17
|
-
"@jskit-ai/users-core": "0.2.
|
|
16
|
+
"@jskit-ai/uploads-image-web": "0.2.7",
|
|
17
|
+
"@jskit-ai/users-core": "0.2.8"
|
|
18
18
|
},
|
|
19
19
|
"peerDependencies": {
|
|
20
|
-
"@jskit-ai/auth-web": "0.2.
|
|
21
|
-
"@jskit-ai/http-runtime": "0.2.
|
|
22
|
-
"@jskit-ai/http-web": "0.2.
|
|
23
|
-
"@jskit-ai/kernel": "0.2.
|
|
24
|
-
"@jskit-ai/shell-web": "0.2.
|
|
20
|
+
"@jskit-ai/auth-web": "0.2.10",
|
|
21
|
+
"@jskit-ai/http-runtime": "0.2.7",
|
|
22
|
+
"@jskit-ai/http-web": "0.2.13",
|
|
23
|
+
"@jskit-ai/kernel": "0.2.7",
|
|
24
|
+
"@jskit-ai/shell-web": "0.2.8",
|
|
25
25
|
"@tanstack/vue-query": "^5.90.5",
|
|
26
26
|
"vue": "^3.5.13",
|
|
27
27
|
"vue-router": "^5.0.4",
|
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import process from "node:process";
|
|
7
|
+
import { after, before, test } from "node:test";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
import { chromium, expect } from "@playwright/test";
|
|
10
|
+
import { en as vuetifyEnglish } from "vuetify/locale";
|
|
11
|
+
import { flattenCatalog, formatMessage } from "@jskit-ai/kernel/shared/i18n";
|
|
12
|
+
import { createChromiumLaunchOptions, startViteFixture, stopProcess } from "../../../tooling/testUtils/browserFixture.mjs";
|
|
13
|
+
|
|
14
|
+
// The translation acceptance test: an app exports a pseudo-locale with
|
|
15
|
+
// `jskit i18n export`, translates every message, switches to it, and no JSKIT
|
|
16
|
+
// screen it opens shows English.
|
|
17
|
+
const RUN = process.env.JSKIT_USERS_WEB_BROWSER_INTEGRATION === "1";
|
|
18
|
+
const options = { skip: !RUN, timeout: 120_000 };
|
|
19
|
+
const LOCALE = "en-XA";
|
|
20
|
+
const REPOSITORY_ROOT = fileURLToPath(new URL("../../../", import.meta.url));
|
|
21
|
+
const JSKIT_CLI = path.join(REPOSITORY_ROOT, "tooling", "jskit-catalog", "scripts", "jskit.mjs");
|
|
22
|
+
const FIXTURE_ROOT = fileURLToPath(new URL("../fixtures/translated-app/", import.meta.url));
|
|
23
|
+
const APP_MESSAGES = JSON.parse(fs.readFileSync(path.join(FIXTURE_ROOT, "appMessages.json"), "utf8"));
|
|
24
|
+
|
|
25
|
+
// Every ASCII letter becomes an accented one, so any ASCII word left on a page
|
|
26
|
+
// is text that did not come through the translation.
|
|
27
|
+
const ASCII_LETTERS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
|
28
|
+
const PSEUDO_LETTERS = [..."áƀçđéƒĝĥíĵķĺɱñóþʠŕšţúṽŵẋýžÁƁÇĐÉƑĜĤÍĴĶĹṀÑÓÞǪŔŠŢÚṼŴẊÝŽ"];
|
|
29
|
+
const PSEUDO = new Map([...ASCII_LETTERS].map((letter, index) => [letter, PSEUDO_LETTERS[index]]));
|
|
30
|
+
// Words that are the fixture's data (names, email, providers), not interface text.
|
|
31
|
+
const DATA_WORDS = new Set(["Ada", "AD", "AL", "Lovelace", "ada", "example", "com", "Google", "GitHub", "Local"]);
|
|
32
|
+
// Vuetify's own labels ("{0} appended action") are translated through Vuetify's
|
|
33
|
+
// locale, not JSKIT's.
|
|
34
|
+
const VUETIFY_TEXTS = Object.values(flattenCatalog(vuetifyEnglish))
|
|
35
|
+
.map(String)
|
|
36
|
+
.filter((message) => /[A-Za-z]{2,}/u.test(message.replace(/\{\d+\}/gu, "")))
|
|
37
|
+
.map((message) => new RegExp(
|
|
38
|
+
`^${message.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&").replace(/\\\{\d+\\\}/gu, ".*")}$`,
|
|
39
|
+
"u"
|
|
40
|
+
));
|
|
41
|
+
|
|
42
|
+
const CAPABILITIES = {
|
|
43
|
+
provider: { id: "local", label: "Local" },
|
|
44
|
+
features: {
|
|
45
|
+
password: { login: true, register: true },
|
|
46
|
+
passwordRecovery: { request: true, complete: true },
|
|
47
|
+
otp: { login: true },
|
|
48
|
+
oauthLogin: { enabled: true, defaultProvider: "google", providers: [{ id: "google", label: "Google" }] },
|
|
49
|
+
emailConfirmation: true
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
const SIGNED_OUT = { authenticated: false, csrfToken: "fixture-csrf", authCapabilities: CAPABILITIES };
|
|
53
|
+
const SIGNED_IN = { ...SIGNED_OUT, authenticated: true, username: "Ada Lovelace", email: "ada@example.com" };
|
|
54
|
+
|
|
55
|
+
let projectRoot = "";
|
|
56
|
+
let english = {};
|
|
57
|
+
let messages = {};
|
|
58
|
+
let pseudo = {};
|
|
59
|
+
let runtime = null;
|
|
60
|
+
let browser = null;
|
|
61
|
+
|
|
62
|
+
// Accents the letters outside {placeholders}; placeholders, quoted literals and
|
|
63
|
+
// the | between plural choices stay as written.
|
|
64
|
+
function pseudoMessage(message) {
|
|
65
|
+
let result = "";
|
|
66
|
+
for (let index = 0; index < message.length; index += 1) {
|
|
67
|
+
if (message[index] !== "{") {
|
|
68
|
+
result += PSEUDO.get(message[index]) ?? message[index];
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
let end = index + 1;
|
|
72
|
+
let quoted = false;
|
|
73
|
+
while (end < message.length && (message[end] !== "}" || quoted)) {
|
|
74
|
+
if (message[end] === "'") quoted = !quoted;
|
|
75
|
+
end += 1;
|
|
76
|
+
}
|
|
77
|
+
result += message.slice(index, end + 1);
|
|
78
|
+
index = end;
|
|
79
|
+
}
|
|
80
|
+
return result;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function pseudoCatalog(catalog) {
|
|
84
|
+
return Object.fromEntries(Object.entries(catalog).map(([name, value]) => [
|
|
85
|
+
name,
|
|
86
|
+
typeof value === "string" ? pseudoMessage(value) : pseudoCatalog(value)
|
|
87
|
+
]));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function jskit(...args) {
|
|
91
|
+
return execFileSync(process.execPath, [JSKIT_CLI, "i18n", ...args, "--root", projectRoot], { encoding: "utf8" });
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The English of a JSKIT key, as a server sends it. */
|
|
95
|
+
function englishOf(key, params = {}) {
|
|
96
|
+
assert.ok(Object.hasOwn(english, key), `${key} is not in the exported English`);
|
|
97
|
+
return formatMessage(english[key], params);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The pseudo-locale text of a JSKIT or app key, as the page shows it. */
|
|
101
|
+
function shown(key, params = {}) {
|
|
102
|
+
const message = pseudo[key];
|
|
103
|
+
assert.ok(message !== undefined, `${key} is not translated`);
|
|
104
|
+
return formatMessage(message, params);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
before(async () => {
|
|
108
|
+
if (!RUN) return;
|
|
109
|
+
// A throwaway app that installs users-web, with the repository's packages as its node_modules.
|
|
110
|
+
projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "jskit-translated-app-"));
|
|
111
|
+
fs.writeFileSync(path.join(projectRoot, "package.json"), JSON.stringify({
|
|
112
|
+
name: "translated-app",
|
|
113
|
+
private: true,
|
|
114
|
+
type: "module",
|
|
115
|
+
dependencies: { "@jskit-ai/users-web": "*" }
|
|
116
|
+
}));
|
|
117
|
+
fs.symlinkSync(path.join(REPOSITORY_ROOT, "node_modules"), path.join(projectRoot, "node_modules"), "dir");
|
|
118
|
+
jskit("export", LOCALE);
|
|
119
|
+
const file = path.join(projectRoot, "src", "locales", LOCALE, "jskit.json");
|
|
120
|
+
const exported = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
121
|
+
english = flattenCatalog(exported);
|
|
122
|
+
fs.writeFileSync(file, `${JSON.stringify(pseudoCatalog(exported), null, 2)}\n`);
|
|
123
|
+
jskit("check", LOCALE);
|
|
124
|
+
// What the app loads for the locale: the translated JSKIT file and its own messages.
|
|
125
|
+
messages = { ...JSON.parse(fs.readFileSync(file, "utf8")), ...pseudoCatalog(APP_MESSAGES) };
|
|
126
|
+
pseudo = flattenCatalog(messages);
|
|
127
|
+
|
|
128
|
+
runtime = await startViteFixture({ fixtureRoot: FIXTURE_ROOT });
|
|
129
|
+
browser = await chromium.launch(createChromiumLaunchOptions());
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
after(async () => {
|
|
133
|
+
await browser?.close();
|
|
134
|
+
await stopProcess(runtime);
|
|
135
|
+
if (projectRoot) fs.rmSync(projectRoot, { recursive: true, force: true });
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
/** One page in the pseudo-locale with the API behind it; anything unexpected under /api is a failure. */
|
|
139
|
+
async function openApp({ session = SIGNED_OUT, routes = {} } = {}) {
|
|
140
|
+
const page = await browser.newPage({ baseURL: runtime.baseURL, viewport: { width: 1280, height: 900 } });
|
|
141
|
+
page.setDefaultTimeout(15_000);
|
|
142
|
+
page.setDefaultNavigationTimeout(60_000);
|
|
143
|
+
await page.addInitScript((translation) => {
|
|
144
|
+
window.translatedFixture = translation;
|
|
145
|
+
}, { locale: LOCALE, messages });
|
|
146
|
+
const api = { session, requests: [], unexpected: [], errors: [], diagnostics: [], page };
|
|
147
|
+
page.on("pageerror", (error) => api.errors.push(error.message));
|
|
148
|
+
page.on("console", (message) => {
|
|
149
|
+
if (message.type() === "error") api.diagnostics.push(message.text());
|
|
150
|
+
});
|
|
151
|
+
page.on("response", (response) => {
|
|
152
|
+
if (response.status() >= 400 && !new URL(response.url()).pathname.startsWith("/api/")) {
|
|
153
|
+
api.diagnostics.push(`${response.status()} ${response.url()}`);
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
// Opens a page and waits for the app to mount, naming what stopped it otherwise.
|
|
157
|
+
api.open = async (url) => {
|
|
158
|
+
await page.goto(url);
|
|
159
|
+
await page.waitForFunction(() => document.querySelector("#app")?.children.length > 0, null, { timeout: 20_000 })
|
|
160
|
+
.catch(() => {
|
|
161
|
+
throw new Error(`the app did not mount at ${url}: ${JSON.stringify([...api.errors, ...api.diagnostics])}`);
|
|
162
|
+
});
|
|
163
|
+
};
|
|
164
|
+
await page.route("**/api/**", async (route) => {
|
|
165
|
+
const request = route.request();
|
|
166
|
+
const { pathname } = new URL(request.url());
|
|
167
|
+
if (!pathname.startsWith("/api/")) {
|
|
168
|
+
await route.continue();
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
const key = `${request.method()} ${pathname}`;
|
|
172
|
+
if (key === "GET /api/session") {
|
|
173
|
+
await route.fulfill({ json: api.session });
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
api.requests.push(key);
|
|
177
|
+
if (!routes[key]) {
|
|
178
|
+
api.unexpected.push(key);
|
|
179
|
+
await route.fulfill({ status: 500, json: { error: `unexpected ${key}` } });
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
const answer = typeof routes[key] === "function" ? routes[key]() : routes[key];
|
|
183
|
+
await (answer === "abort" ? route.abort("internetdisconnected") : route.fulfill(answer));
|
|
184
|
+
});
|
|
185
|
+
return api;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Lines of visible text and visible labelling attributes that still carry an ASCII word. */
|
|
189
|
+
async function leftoverEnglish(page) {
|
|
190
|
+
const texts = await page.evaluate(() => {
|
|
191
|
+
const found = document.body.innerText.split("\n");
|
|
192
|
+
for (const element of document.querySelectorAll("[placeholder], [title], [aria-label], [alt]")) {
|
|
193
|
+
if (!element.checkVisibility()) continue;
|
|
194
|
+
for (const name of ["placeholder", "title", "aria-label", "alt"]) {
|
|
195
|
+
const value = element.getAttribute(name);
|
|
196
|
+
if (value) found.push(value);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return found;
|
|
200
|
+
});
|
|
201
|
+
return texts.filter((line) => {
|
|
202
|
+
const value = line.trim();
|
|
203
|
+
if (!value || VUETIFY_TEXTS.some((pattern) => pattern.test(value))) return false;
|
|
204
|
+
return (value.match(/[A-Za-z]{2,}/gu) || []).some((word) => !DATA_WORDS.has(word));
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async function assertTranslated(page, screen) {
|
|
209
|
+
assert.deepEqual(await leftoverEnglish(page), [], `${screen} shows English`);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async function finish(api) {
|
|
213
|
+
assert.deepEqual(api.unexpected, []);
|
|
214
|
+
assert.deepEqual(api.errors, []);
|
|
215
|
+
await api.page.close();
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
test("the export holds every rendered package's English and the pseudo-locale passes jskit i18n check", options, () => {
|
|
219
|
+
for (const namespace of ["auth", "users", "shell", "http", "kernel", "crud"]) {
|
|
220
|
+
assert.ok(Object.keys(english).some((key) => key.startsWith(`jskit.${namespace}.`)), `no jskit.${namespace} keys exported`);
|
|
221
|
+
}
|
|
222
|
+
assert.equal(shown("jskit.auth.login.field.email"), "Éɱáíĺ");
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test("sign-in, registration and password reset show only the pseudo-locale", options, async () => {
|
|
226
|
+
const api = await openApp({
|
|
227
|
+
routes: {
|
|
228
|
+
"POST /api/login": {
|
|
229
|
+
status: 401,
|
|
230
|
+
json: { error: englishOf("jskit.auth.login.invalidCredentials"), code: "auth.login.invalidCredentials" }
|
|
231
|
+
},
|
|
232
|
+
"POST /api/password/forgot": { json: { ok: true } }
|
|
233
|
+
}
|
|
234
|
+
});
|
|
235
|
+
const { page } = api;
|
|
236
|
+
await api.open("/auth/login?returnTo=/home");
|
|
237
|
+
await expect(page.getByTestId("auth-mode-register")).toBeVisible();
|
|
238
|
+
await assertTranslated(page, "sign-in");
|
|
239
|
+
|
|
240
|
+
await page.getByLabel(shown("jskit.auth.login.field.email")).fill("ada@example.com");
|
|
241
|
+
await page.getByLabel(shown("jskit.auth.login.field.password"), { exact: true }).fill("wrong horse");
|
|
242
|
+
await page.getByTestId("auth-submit").click();
|
|
243
|
+
await expect(page.getByText(shown("jskit.auth.login.invalidCredentials")).first()).toBeVisible();
|
|
244
|
+
await assertTranslated(page, "a refused sign-in");
|
|
245
|
+
|
|
246
|
+
await page.getByTestId("auth-mode-register").click();
|
|
247
|
+
await expect(page.getByText(shown("jskit.auth.login.title.register"))).toBeVisible();
|
|
248
|
+
await page.getByLabel(shown("jskit.auth.login.field.password"), { exact: true }).fill("Correct-horse-1");
|
|
249
|
+
await page.getByLabel(shown("jskit.auth.login.field.confirmPassword"), { exact: true }).fill("Different-horse-1");
|
|
250
|
+
await page.getByLabel(shown("jskit.auth.login.field.confirmPassword"), { exact: true }).blur();
|
|
251
|
+
await expect(page.getByText(shown("jskit.auth.login.passwordsMismatch"))).toBeVisible();
|
|
252
|
+
await assertTranslated(page, "registration");
|
|
253
|
+
|
|
254
|
+
await page.getByTestId("auth-mode-sign-in").click();
|
|
255
|
+
await page.getByRole("button", { name: shown("jskit.auth.login.forgotPassword") }).click();
|
|
256
|
+
await expect(page.getByText(shown("jskit.auth.login.title.forgot"))).toBeVisible();
|
|
257
|
+
await assertTranslated(page, "password reset request");
|
|
258
|
+
await page.getByLabel(shown("jskit.auth.login.field.email")).fill("ada@example.com");
|
|
259
|
+
await page.getByTestId("auth-submit").click();
|
|
260
|
+
await expect(page.getByText(shown("jskit.auth.login.resetSent")).first()).toBeVisible();
|
|
261
|
+
await assertTranslated(page, "password reset sent");
|
|
262
|
+
await finish(api);
|
|
263
|
+
|
|
264
|
+
const reset = await openApp({
|
|
265
|
+
routes: { "POST /api/password/recovery": { json: { ok: true } }, "POST /api/password/reset": { json: { ok: true } } }
|
|
266
|
+
});
|
|
267
|
+
await reset.open("/auth/reset-password#access_token=token-a&refresh_token=token-r&type=recovery");
|
|
268
|
+
const newPassword = reset.page.getByLabel(shown("jskit.auth.resetPassword.newPassword"), { exact: true });
|
|
269
|
+
await expect(newPassword).toBeVisible();
|
|
270
|
+
await newPassword.fill("Correct-horse-1");
|
|
271
|
+
await reset.page.getByLabel(shown("jskit.auth.resetPassword.confirmPassword"), { exact: true }).fill("Different-horse-1");
|
|
272
|
+
await expect(reset.page.getByText(shown("jskit.auth.resetPassword.passwordsMustMatch"))).toBeVisible();
|
|
273
|
+
await assertTranslated(reset.page, "password reset");
|
|
274
|
+
await reset.page.getByLabel(shown("jskit.auth.resetPassword.confirmPassword"), { exact: true }).fill("Correct-horse-1");
|
|
275
|
+
await reset.page.getByRole("button", { name: shown("jskit.auth.resetPassword.submit") }).click();
|
|
276
|
+
await expect(reset.page.getByText(shown("jskit.auth.resetPassword.done"))).toBeVisible();
|
|
277
|
+
await assertTranslated(reset.page, "password updated");
|
|
278
|
+
await finish(reset);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
test("the profile menu and a refused, then retried, sign-out show only the pseudo-locale", options, async () => {
|
|
282
|
+
let failures = 1;
|
|
283
|
+
const api = await openApp({
|
|
284
|
+
session: SIGNED_IN,
|
|
285
|
+
routes: {
|
|
286
|
+
"POST /api/logout": () => {
|
|
287
|
+
if (failures > 0) {
|
|
288
|
+
failures -= 1;
|
|
289
|
+
return { status: 503, json: { error: englishOf("jskit.auth.session.unavailable"), code: "auth.session.unavailable" } };
|
|
290
|
+
}
|
|
291
|
+
api.session = SIGNED_OUT;
|
|
292
|
+
return { json: { ok: true } };
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
});
|
|
296
|
+
const { page } = api;
|
|
297
|
+
await api.open("/home");
|
|
298
|
+
await page.locator(".auth-profile-widget__activator").click();
|
|
299
|
+
const signOut = page.getByRole("link", { name: shown("app.menu.signOut") });
|
|
300
|
+
await expect(signOut).toBeVisible();
|
|
301
|
+
await assertTranslated(page, "the profile menu");
|
|
302
|
+
|
|
303
|
+
await signOut.click();
|
|
304
|
+
await expect(page.getByText(shown("jskit.auth.session.unavailable")).first()).toBeVisible();
|
|
305
|
+
await assertTranslated(page, "a refused sign-out");
|
|
306
|
+
await page.getByRole("button", { name: shown("jskit.auth.signOut.retry") }).click();
|
|
307
|
+
await page.waitForURL("**/auth/login**");
|
|
308
|
+
await expect(page.getByTestId("auth-submit")).toBeVisible();
|
|
309
|
+
await api.open("/home");
|
|
310
|
+
await expect(page.getByText(shown("jskit.auth.profile.guest"))).toBeVisible();
|
|
311
|
+
await assertTranslated(page, "the signed-out profile widget");
|
|
312
|
+
await finish(api);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
function settingsDocument(theme = "dark") {
|
|
316
|
+
return {
|
|
317
|
+
contentType: "application/vnd.api+json",
|
|
318
|
+
json: {
|
|
319
|
+
data: {
|
|
320
|
+
type: "user-settings",
|
|
321
|
+
id: "7",
|
|
322
|
+
attributes: {
|
|
323
|
+
profile: {
|
|
324
|
+
displayName: "Ada",
|
|
325
|
+
email: "ada@example.com",
|
|
326
|
+
avatar: { uploadedUrl: "", gravatarUrl: "", effectiveUrl: "", hasUploadedAvatar: true, size: 64, version: "1" }
|
|
327
|
+
},
|
|
328
|
+
preferences: {
|
|
329
|
+
theme,
|
|
330
|
+
locale: "en-US",
|
|
331
|
+
timeZone: "UTC",
|
|
332
|
+
dateFormat: "system",
|
|
333
|
+
numberFormat: "system",
|
|
334
|
+
currencyCode: "USD",
|
|
335
|
+
avatarSize: 64
|
|
336
|
+
},
|
|
337
|
+
notifications: { productUpdates: true, accountActivity: false, securityAlerts: true },
|
|
338
|
+
security: { authMethods: [{ kind: "password", provider: "local", configured: true }] }
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
test("account settings, their feedback and a refused field show only the pseudo-locale", options, async () => {
|
|
346
|
+
let saves = 0;
|
|
347
|
+
const limit = { limit: 32 };
|
|
348
|
+
const api = await openApp({
|
|
349
|
+
session: SIGNED_IN,
|
|
350
|
+
routes: {
|
|
351
|
+
"GET /api/settings": settingsDocument(),
|
|
352
|
+
"PATCH /api/settings/preferences": () => {
|
|
353
|
+
saves += 1;
|
|
354
|
+
return saves === 1
|
|
355
|
+
? {
|
|
356
|
+
status: 422,
|
|
357
|
+
json: {
|
|
358
|
+
error: englishOf("jskit.errors.validation_failed"),
|
|
359
|
+
code: "validation_failed",
|
|
360
|
+
details: {
|
|
361
|
+
fieldErrors: { theme: englishOf("jskit.kernel.validation.maxLength", limit) },
|
|
362
|
+
fieldErrorCodes: { theme: { code: "kernel.validation.maxLength", params: limit } }
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
: settingsDocument("light");
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
});
|
|
370
|
+
const { page } = api;
|
|
371
|
+
await api.open("/account?returnTo=/home");
|
|
372
|
+
await expect(page.getByText(shown("jskit.users.accountSettings.profile.save"))).toBeVisible();
|
|
373
|
+
await assertTranslated(page, "account settings");
|
|
374
|
+
|
|
375
|
+
await page.getByRole("listitem").filter({ hasText: shown("app.settings.preferences") }).click();
|
|
376
|
+
await page.waitForURL("**section=preferences**");
|
|
377
|
+
await page.getByTestId("theme-select").click();
|
|
378
|
+
await expect(page.getByRole("option", { name: shown("jskit.users.accountSettings.preferences.theme.light") })).toBeVisible();
|
|
379
|
+
await assertTranslated(page, "the theme choices");
|
|
380
|
+
await page.keyboard.press("Escape");
|
|
381
|
+
|
|
382
|
+
await page.getByTestId("preferences-save").click();
|
|
383
|
+
await expect(page.getByText(shown("jskit.kernel.validation.maxLength", limit))).toBeVisible();
|
|
384
|
+
await assertTranslated(page, "a refused preference");
|
|
385
|
+
await page.getByTestId("preferences-save").click();
|
|
386
|
+
await expect(page.getByText(shown("jskit.users.accountSettings.preferences.saved"))).toBeVisible();
|
|
387
|
+
await assertTranslated(page, "saved preferences");
|
|
388
|
+
await finish(api);
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
test("shell request recovery and http errors show only the pseudo-locale", options, async () => {
|
|
392
|
+
const api = await openApp({
|
|
393
|
+
session: SIGNED_IN,
|
|
394
|
+
routes: {
|
|
395
|
+
"GET /api/offline": "abort",
|
|
396
|
+
"GET /api/broken": { status: 500, body: "" }
|
|
397
|
+
}
|
|
398
|
+
});
|
|
399
|
+
const { page } = api;
|
|
400
|
+
await api.open("/errors");
|
|
401
|
+
await expect(page.getByText(shown("jskit.http.request.failedWithStatus", { status: 500 }))).toBeVisible();
|
|
402
|
+
await expect(page.getByText(shown("jskit.shell.recovery.appUnreachable"))).toBeVisible();
|
|
403
|
+
await assertTranslated(page, "request errors");
|
|
404
|
+
await finish(api);
|
|
405
|
+
});
|