приложение собрано каркасом и вшито в бинарник
- заведён каталог web/ — Vue 3, роутер пятой версии, сборка Vite; собранное вшивается через go:embed и раздаётся корневым маршрутом: разметка на неизвестном пути вне корней сервиса, отказ контракта внутри корня - перечень корней сервиса стал единой точкой и порождает регистрацию маршрутов, а не описывает её; журнал раздачи пишет исход и длину пути, но не сам путь - шаг front зовёт Node контейнером docker — Biome, юнит-тесты Vue и сборка входят в гейт, а в Dockerfile появилась ступень приложения
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.5.8/schema.json",
|
||||
"files": {
|
||||
"includes": ["src/**", "*.ts", "*.json"]
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"indentStyle": "space",
|
||||
"indentWidth": 2,
|
||||
"lineWidth": 100
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"preset": "recommended"
|
||||
}
|
||||
},
|
||||
"javascript": {
|
||||
"formatter": {
|
||||
"quoteStyle": "single",
|
||||
"semicolons": "asNeeded"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// Package web несёт собранное приложение внутри бинарника.
|
||||
//
|
||||
// Каталога рядом с бинарником не требуется, и внешнего веб-сервера под раздачу
|
||||
// не заводится: сервис едет на сервер одним образом, и второй разворачиваемый
|
||||
// артефакт рядом с ним завёл бы вторую точку, где выкладка расходится с
|
||||
// собранным.
|
||||
package web
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
)
|
||||
|
||||
// Вшивается каталог `embed`, а не `embed/dist`: собранное приложение лежит
|
||||
// этажом ниже метки `embed/.gitkeep`, и очистка выходного каталога перед каждой
|
||||
// сборкой метки не касается. Без метки каталога не было бы в git вовсе, и
|
||||
// `go build ./...` отказывал бы у всякого, кто приложение ни разу не собирал.
|
||||
//
|
||||
// Приставка `all:` нужна ради самой метки: без неё файлы, чьё имя начинается с
|
||||
// точки, не вшиваются.
|
||||
//
|
||||
//go:embed all:embed
|
||||
var embedded embed.FS
|
||||
|
||||
// distDir — где внутри вшитого лежит собранное приложение.
|
||||
const distDir = "embed/dist"
|
||||
|
||||
// Dist отдаёт собранное приложение файловой системой.
|
||||
//
|
||||
// Второе значение говорит, собрано ли оно вообще: пустой каталог — законное
|
||||
// состояние сборки, прошедшей мимо набора проверок, и сервис обязан сказать о
|
||||
// нём вслух, а не отдавать пустую страницу.
|
||||
func Dist() (fs.FS, bool) {
|
||||
dist, err := fs.Sub(embedded, distDir)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
if _, err := fs.Stat(dist, "index.html"); err != nil {
|
||||
return dist, false
|
||||
}
|
||||
|
||||
return dist, true
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Стык вшивания с раздачей проверяется здесь, и только здесь: проверки самой
|
||||
// раздачи получают файловую систему параметром и подставляют свою, поэтому
|
||||
// расхождение объявленной раскладки с настоящей им недоступно по построению.
|
||||
//
|
||||
// Раскладка объявлена дважды — выходным каталогом сборщика и `distDir` здесь, —
|
||||
// и связаны они только совпадением строк. Разойдясь, они дают `503` на каждом
|
||||
// пути показа при зелёном наборе проверок.
|
||||
func TestDistMatchesEmbeddedLayout(t *testing.T) {
|
||||
dist, built := Dist()
|
||||
|
||||
if dist == nil {
|
||||
t.Fatal("вшитое поддерево недоступно: distDir разошёлся с директивой go:embed")
|
||||
}
|
||||
|
||||
_, err := fs.Stat(dist, "index.html")
|
||||
|
||||
// Оба исхода законны и различаются одним: собрано приложение или нет.
|
||||
// Незаконно третье — «собрано, но `Dist` этого не видит».
|
||||
if built && err != nil {
|
||||
t.Fatalf("приложение объявлено собранным, а разметки в поддереве нет: %v", err)
|
||||
}
|
||||
|
||||
if !built && err == nil {
|
||||
t.Fatal("разметка есть, а приложение объявлено несобранным")
|
||||
}
|
||||
|
||||
if !built {
|
||||
t.Skip("приложение не собрано: прогон без шага сборки, проверять нечего")
|
||||
}
|
||||
}
|
||||
|
||||
// Метка пустого каталога вшивается вместе с прочим: без неё каталога не было бы
|
||||
// в git, и сборка Go отказывала бы у всякого, кто приложение ни разу не собирал.
|
||||
// Приставка `all:` заведена ровно ради неё.
|
||||
func TestMarkerIsEmbedded(t *testing.T) {
|
||||
if _, err := fs.Stat(embedded, "embed/.gitkeep"); err != nil {
|
||||
t.Fatalf("метка пустого каталога не вшита: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Расшифровка записей</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+2967
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "transcriber-web",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"check": "biome check .",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.5.41",
|
||||
"vue-router": "^5.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.5.8",
|
||||
"@vitejs/plugin-vue": "^6.0.8",
|
||||
"@vue/test-utils": "^2.4.11",
|
||||
"happy-dom": "^20.11.2",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^8.2.1",
|
||||
"vitest": "^4.1.10",
|
||||
"vue-tsc": "^3.3.10"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<RouterView />
|
||||
</template>
|
||||
@@ -0,0 +1,77 @@
|
||||
// Единственное место, где приложение читает код ответа и разбирает тело отказа.
|
||||
// Экран получает готовый текст, а не `Response`: второй разборщик кодов на
|
||||
// клиенте разошёлся бы с первым.
|
||||
|
||||
const appRoot = '/app'
|
||||
|
||||
/** Ответ сервиса на вопрос «кто вошёл». */
|
||||
export interface Me {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
/** Единая форма тела отказа на адресах приложения. */
|
||||
interface ErrorBody {
|
||||
error_code?: string
|
||||
message?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Исход запроса. Отсутствие сессии — свой исход, а не разновидность неудачи:
|
||||
* экран уводит ко входу только на нём. Всякий прочий отказ ко входу не ведёт,
|
||||
* иначе отказ сервиса или ограничителя частоты замкнул бы круг «приложение →
|
||||
* вход → приложение».
|
||||
*/
|
||||
export type ApiResult<T> =
|
||||
| { status: 'ok'; value: T }
|
||||
| { status: 'unauthorized' }
|
||||
| { status: 'failed'; message: string }
|
||||
|
||||
const noConnectionMessage = 'Связи нет'
|
||||
const unknownFailureMessage = 'Сервис не отвечает как надо'
|
||||
|
||||
async function failureMessage(response: Response): Promise<string> {
|
||||
try {
|
||||
const body = (await response.json()) as ErrorBody
|
||||
if (body.message) {
|
||||
return body.message
|
||||
}
|
||||
} catch {
|
||||
// Тело не разобралось — своего текста под код ответа не сочиняем.
|
||||
}
|
||||
return unknownFailureMessage
|
||||
}
|
||||
|
||||
async function request<T>(path: string): Promise<ApiResult<T>> {
|
||||
let response: Response
|
||||
try {
|
||||
response = await fetch(appRoot + path, {
|
||||
headers: { Accept: 'application/json' },
|
||||
})
|
||||
} catch {
|
||||
// Сорванный запрос — состояние, а не ошибка: показываем строкой.
|
||||
return { status: 'failed', message: noConnectionMessage }
|
||||
}
|
||||
|
||||
if (response.status === 401) {
|
||||
return { status: 'unauthorized' }
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
return { status: 'failed', message: await failureMessage(response) }
|
||||
}
|
||||
|
||||
try {
|
||||
return { status: 'ok', value: (await response.json()) as T }
|
||||
} catch {
|
||||
return { status: 'failed', message: unknownFailureMessage }
|
||||
}
|
||||
}
|
||||
|
||||
/** Спрашивает сервис, кто вошёл. Куку сессии браузер шлёт сам. */
|
||||
export function fetchMe(): Promise<ApiResult<Me>> {
|
||||
return request<Me>('/me')
|
||||
}
|
||||
|
||||
/** Адрес, которым сервис уводит человека ко входу. */
|
||||
export const loginPath = '/auth/login'
|
||||
@@ -0,0 +1,5 @@
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import { router } from './router'
|
||||
|
||||
createApp(App).use(router).mount('#app')
|
||||
@@ -0,0 +1,12 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import HomeScreen from './screens/HomeScreen.vue'
|
||||
|
||||
// Одна таблица маршрутов. Маршруты по файлам не включаем: сборочная надстройка
|
||||
// роутера на нашем числе экранов не окупается.
|
||||
//
|
||||
// Адреса обычные, а не после решётки. Отсюда требование к серверу: неизвестный
|
||||
// путь вне корней сервиса отдаёт разметку, а не отказ.
|
||||
export const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [{ path: '/', name: 'home', component: HomeScreen }],
|
||||
})
|
||||
@@ -0,0 +1,86 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import HomeScreen from './HomeScreen.vue'
|
||||
|
||||
// Экран судится по тому, что видит человек, а не по внутреннему состоянию:
|
||||
// переписанная реализация обязана остаться зелёной.
|
||||
|
||||
const assign = vi.fn()
|
||||
|
||||
vi.stubGlobal('window', { location: { assign } })
|
||||
|
||||
function answerWith(response: Partial<Response> & { json?: () => Promise<unknown> }) {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(response))
|
||||
}
|
||||
|
||||
function failWith(error: Error) {
|
||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(error))
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
assign.mockReset()
|
||||
})
|
||||
|
||||
describe('экран показывает вошедшего', () => {
|
||||
it('показывает имя, когда сессия есть', async () => {
|
||||
answerWith({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => Promise.resolve({ id: 'u1', name: 'Антон' }),
|
||||
})
|
||||
|
||||
const screen = mount(HomeScreen)
|
||||
await flushPromises()
|
||||
|
||||
expect(screen.text()).toContain('Антон')
|
||||
expect(assign).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ведёт ко входу, когда сессии нет', async () => {
|
||||
answerWith({ ok: false, status: 401, json: () => Promise.resolve({}) })
|
||||
|
||||
mount(HomeScreen)
|
||||
await flushPromises()
|
||||
|
||||
expect(assign).toHaveBeenCalledWith('/auth/login')
|
||||
})
|
||||
|
||||
it('на прочий отказ показывает строку и ко входу не ведёт', async () => {
|
||||
answerWith({
|
||||
ok: false,
|
||||
status: 500,
|
||||
json: () => Promise.resolve({ error_code: 'internal', message: 'Внутренняя ошибка сервиса' }),
|
||||
})
|
||||
|
||||
const screen = mount(HomeScreen)
|
||||
await flushPromises()
|
||||
|
||||
expect(screen.text()).toContain('Внутренняя ошибка сервиса')
|
||||
expect(assign).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('на сорванный запрос показывает, что связи нет', async () => {
|
||||
failWith(new Error('network down'))
|
||||
|
||||
const screen = mount(HomeScreen)
|
||||
await flushPromises()
|
||||
|
||||
expect(screen.text()).toContain('Связи нет')
|
||||
expect(assign).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('учётная запись без имени: вход выполнен, почты на экране нет', async () => {
|
||||
answerWith({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => Promise.resolve({ id: 'u1', name: '' }),
|
||||
})
|
||||
|
||||
const screen = mount(HomeScreen)
|
||||
await flushPromises()
|
||||
|
||||
expect(screen.text()).toContain('Вы вошли')
|
||||
expect(screen.text()).not.toContain('@')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,46 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { fetchMe, loginPath } from '../api'
|
||||
|
||||
// Состояние экрана живёт в экране: общего между экранами пока нет.
|
||||
const state = ref<'loading' | 'signed-in' | 'failed'>('loading')
|
||||
const name = ref('')
|
||||
const failure = ref('')
|
||||
|
||||
async function load() {
|
||||
const result = await fetchMe()
|
||||
|
||||
if (result.status === 'unauthorized') {
|
||||
// Единственная ветка, уводящая ко входу. Прочие отказы ведут сюда же
|
||||
// только через круг «приложение → вход → приложение», поэтому их здесь нет.
|
||||
window.location.assign(loginPath)
|
||||
return
|
||||
}
|
||||
|
||||
if (result.status === 'failed') {
|
||||
failure.value = result.message
|
||||
state.value = 'failed'
|
||||
return
|
||||
}
|
||||
|
||||
name.value = result.value.name
|
||||
state.value = 'signed-in'
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main>
|
||||
<h1>Расшифровка записей</h1>
|
||||
|
||||
<p v-if="state === 'loading'">Загружается…</p>
|
||||
|
||||
<p v-else-if="state === 'failed'">{{ failure }}</p>
|
||||
|
||||
<!-- Имени у учётной записи может не быть вовсе: тогда говорим, что вход
|
||||
выполнен, и не подставляем вместо имени адрес почты — его в ответе нет. -->
|
||||
<p v-else-if="name">Вы вошли как {{ name }}</p>
|
||||
<p v-else>Вы вошли</p>
|
||||
</main>
|
||||
</template>
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"isolatedModules": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"skipLibCheck": true,
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.vue", "vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
// Настройка сборки и настройка проверок живут одним файлом: второй разошёлся бы
|
||||
// с первым в путях и в наборе подключаемого.
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
build: {
|
||||
// Выходной каталог лежит этажом ниже метки `embed/.gitkeep`: очистка перед
|
||||
// сборкой метки не касается, и `go build ./...` не отказывает у того, кто
|
||||
// приложение ни разу не собирал.
|
||||
outDir: 'embed/dist',
|
||||
emptyOutDir: true,
|
||||
// Имя каталога задано явно, а не оставлено умолчанию: на него опираются два
|
||||
// правила раздачи — отказ вместо разметки на несовпавшем ресурсе и долгий
|
||||
// срок хранения, — и смена умолчания сборщика выключила бы оба молча.
|
||||
// Второй дом имени — `controller/http.assetsDir`.
|
||||
assetsDir: 'assets',
|
||||
},
|
||||
test: {
|
||||
environment: 'happy-dom',
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user