Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 17 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -98,10 +98,21 @@ jobs:
- name: Fumaça no app empacotado
run: node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON scripts/fumaca-empacotado.ts "dist/win-unpacked/WSLC UI.exe"

# O latest.yml é o índice que o auto-updater lê para descobrir que existe
# versão nova. Sem ele a release sai completa aos olhos de quem baixa na
# mão, e invisível para quem já tem o app instalado — erro que só apareceria
# uma versão depois. Por isso a checagem é aqui, e é dura.
- name: Conferir o índice do auto-updater
shell: bash
run: test -f dist/latest.yml || { echo '::error::latest.yml não foi gerado'; exit 1; }

- uses: actions/upload-artifact@v7
with:
name: instaladores
path: dist/*.exe
path: |
dist/*.exe
dist/*.blockmap
dist/latest.yml
if-no-files-found: error
retention-days: 7

Expand Down Expand Up @@ -146,5 +157,9 @@ jobs:
extra=()
# Versão com sufixo (0.2.0-rc.1) sai marcada como pré-lançamento.
if [[ "$VERSAO" == *-* ]]; then extra+=(--prerelease); fi
gh release create "$TAG" --title "$TAG" --notes-file notas.md --verify-tag "${extra[@]}" dist/*.exe
# O .blockmap é opcional (só o NSIS gera): sem ele o updater baixa o
# instalador inteiro em vez do diff. O latest.yml não é opcional.
shopt -s nullglob
anexos=(dist/*.exe dist/*.blockmap dist/latest.yml)
gh release create "$TAG" --title "$TAG" --notes-file notas.md --verify-tag "${extra[@]}" "${anexos[@]}"
echo "[$TAG](https://github.com/$GITHUB_REPOSITORY/releases/tag/$TAG) publicada." >> "$GITHUB_STEP_SUMMARY"
37 changes: 33 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -272,10 +272,10 @@ Nesta fase o app **encapsula a CLI `wslc.exe`** via `execFile` sem shell (imune

### Fronteiras do processo main

O app fala com o mundo por quatro portas, e **todas são injetáveis**: o serviço da CLI
(`WslcService`), o motor nativo, os streams de longa duração e os efeitos externos (diálogos do
Electron, shell do Windows, busca no Docker Hub). Em produção elas são a implementação real; sob
`WSLC_UI_MOCK` viram dublês. É o que permite exercitar o app inteiro — inclusive o motor nativo —
O app fala com o mundo por cinco portas, e **todas são injetáveis**: o serviço da CLI
(`WslcService`), o motor nativo, os streams de longa duração, os efeitos externos (diálogos do
Electron, shell do Windows, busca no Docker Hub) e o auto-updater. Em produção elas são a
implementação real; sob `WSLC_UI_MOCK` viram dublês. É o que permite exercitar o app inteiro — inclusive o motor nativo —
sem WSL, sem a `wslcsdk.dll` e sem abrir uma única janela do Windows.

## Requisitos
Expand Down Expand Up @@ -519,6 +519,35 @@ desconhecido" e exigir _Mais informações → Executar assim mesmo_.

Versão com sufixo (`0.2.0-rc.1`) sai marcada como pré-lançamento.

### Atualização automática

Da 0.3.0 em diante o app se atualiza sozinho a partir das releases deste repositório
(`electron-updater` com o provedor GitHub). Quem estiver na 0.2.0 precisa instalar a 0.3.0 à mão —
a primeira versão que recebe atualização é a primeira que já traz o updater dentro.

O que o app faz depende de como ele foi instalado:

| Situação | O que acontece |
| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
| Instalado pelo setup | Checa ao abrir e a cada 6 h, baixa em segundo plano e **aplica quando o app fecha**. Dá para aplicar na hora pela aba Sistema. |
| Portátil | Checa, avisa e leva para a release. Trocar o `.exe` é de quem usa — que é o ponto de ser portátil. |
| Rodando do código-fonte | Desligado: não há instalação para atualizar por cima. |

Só versões estáveis contam. Um `0.4.0-rc.1` continua publicado no GitHub, mas o updater o ignora.

O que faz isso funcionar é o **`latest.yml`** que o `electron-builder` gera e o workflow anexa à
release: é o índice que o app consulta. Sem ele, a release fica completa para quem baixa à mão e
invisível para quem já tem o app instalado — falha silenciosa que só apareceria uma versão depois.
Por isso o job de empacotamento **falha** se o arquivo não existir, e o teste de fumaça confere que o
`app-update.yml` foi embutido no pacote.

Instalar pela aba Sistema não é um `quitAndInstall` seco: o app encerra a sessão nativa **antes** de
entregar o processo ao instalador. O NSIS fecha quem demora, e um processo morto assim deixa a sessão
"WslcUi" órfã no WSL.

O ciclo inteiro é exercitável sem release nenhuma: `WSLC_UI_MOCK_UPDATE=portable|disabled` escolhe o
modo e `WSLC_UI_MOCK_FAIL=updates:check,updates:download` reproduz os dois caminhos tristes.

## Licença

Código deste repositório: **MIT** — ver [LICENSE](LICENSE).
Expand Down
108 changes: 108 additions & 0 deletions e2e/atualizacoes.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { expect, test } from './fixtures/app'
import { expectToast, goto } from './fixtures/ui'

/**
* Auto-updater na aba Sistema.
*
* O ciclo real (achar, baixar, instalar) só aconteceria com uma release
* publicada e um app instalado — tarde demais para descobrir que a tela está
* errada. Aqui ele roda contra o updater de demonstração, que percorre os
* mesmos estados em milissegundos e nos três modos.
*/

const chip = (page: import('@playwright/test').Page, texto: string) => page.getByText(texto, { exact: true })

const procurar = (page: import('@playwright/test').Page) =>
page.getByRole('button', { name: 'Procurar atualizações' })

test.describe('Atualizações · com instalador', () => {
test('encontra, baixa e fica pronta para instalar ao fechar', async ({ page }) => {
await goto(page, 'Sistema')
await expect(chip(page, 'não verificado')).toBeVisible()

await procurar(page).click()

await expect(chip(page, 'pronta para instalar')).toBeVisible()
await expectToast(page, /Versão \d+\.\d+\.\d+ pronta/)
await expect(page.getByRole('button', { name: 'Reiniciar e instalar agora' })).toBeVisible()
})

test('mostra as notas da versão nova', async ({ page }) => {
await goto(page, 'Sistema')
await procurar(page).click()

await expect(page.getByText('Atualização automática a partir das releases do GitHub')).toBeVisible()
})

test('instalar encerra o app pelo caminho ordenado', async ({ page }) => {
await goto(page, 'Sistema')
await procurar(page).click()
await page.getByRole('button', { name: 'Reiniciar e instalar agora' }).click()

await page.getByRole('button', { name: 'Expandir logs' }).click()
await expect(page.getByText(/\(demo\) app fechado para instalar a versão/)).toBeVisible()
})
})

test.describe('Atualizações · caminho triste', () => {
test.describe('checagem falha', () => {
test.use({ fail: ['updates:check'] })

test('o erro aparece na própria seção', async ({ page }) => {
await goto(page, 'Sistema')
await procurar(page).click()

await expect(chip(page, 'falhou')).toBeVisible()
await expect(page.getByText(/GitHub respondeu 503/)).toBeVisible()
})
})

test.describe('download falha', () => {
test.use({ fail: ['updates:download'] })

// A versão encontrada continua à vista: é o que permite oferecer o caminho
// manual quando o automático não deu certo.
test('mantém a versão encontrada mesmo tendo falhado', async ({ page }) => {
await goto(page, 'Sistema')
await procurar(page).click()

await expect(chip(page, 'falhou')).toBeVisible()
await expect(page.getByText(/conexão perdida durante o download/)).toBeVisible()
await expect(page.getByText(/^\d+\.\d+\.\d+$/).nth(1)).toBeVisible()
})
})
})

test.describe('Atualizações · portátil', () => {
test.use({ update: 'portable' })

test('avisa e leva para a release, sem instalar nada', async ({ page }) => {
await goto(page, 'Sistema')
await procurar(page).click()

await expect(chip(page, 'versão nova')).toBeVisible()
await expectToast(page, /Versão \d+\.\d+\.\d+ disponível/)
await expect(page.getByRole('button', { name: 'Baixar na release' })).toBeVisible()
await expect(page.getByRole('button', { name: 'Reiniciar e instalar agora' })).toHaveCount(0)
})

test('o link da release é um efeito externo registrado', async ({ page }) => {
await goto(page, 'Sistema')
await procurar(page).click()
await page.getByRole('button', { name: 'Baixar na release' }).click()

await page.getByRole('button', { name: 'Expandir logs' }).click()
await expect(page.getByText(/\(demo\) link aberto no navegador:.*releases\/tag/)).toBeVisible()
})
})

test.describe('Atualizações · rodando do código-fonte', () => {
test.use({ update: 'disabled' })

test('não oferece o que não pode cumprir', async ({ page }) => {
await goto(page, 'Sistema')

await expect(procurar(page)).toBeDisabled()
await expect(page.getByText(/não há instalação para atualizar por cima/)).toBeVisible()
})
})
6 changes: 5 additions & 1 deletion e2e/fixtures/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ export interface AppOptions {
fail: string[]
/** Caminho devolvido pelos diálogos de arquivo; 'cancel' simula cancelar. */
pick: string | undefined
/** Modo do auto-updater simulado — ver WSLC_UI_MOCK_UPDATE em mock-state.ts. */
update: 'installer' | 'portable' | 'disabled'
}

interface AppFixtures {
Expand All @@ -53,8 +55,9 @@ export const test = base.extend<AppOptions & AppFixtures>({
engine: ['cli', { option: true }],
fail: [[], { option: true }],
pick: [undefined, { option: true }],
update: ['installer', { option: true }],

app: async ({ mock, engine, fail, pick }, use) => {
app: async ({ mock, engine, fail, pick, update }, use) => {
const userData = mkdtempSync(join(tmpdir(), 'wslc-ui-e2e-'))
// O motor é lido do settings.json na primeira consulta: semear o arquivo
// faz o app JÁ ABRIR no motor pedido, sem passar pela tela de Sistema.
Expand All @@ -68,6 +71,7 @@ export const test = base.extend<AppOptions & AppFixtures>({
WSLC_UI_MOCK: mock,
WSLC_UI_MOCK_FAIL: fail.join(','),
WSLC_UI_MOCK_TICK_MS: '40',
WSLC_UI_MOCK_UPDATE: update,
...(pick === undefined ? {} : { WSLC_UI_MOCK_PICK: pick })
})
})
Expand Down
10 changes: 8 additions & 2 deletions electron-builder.yml
Original file line number Diff line number Diff line change
Expand Up @@ -63,5 +63,11 @@ nsis:
portable:
artifactName: ${name}-${version}-portable.${ext}

# Não publica sozinho: quem cria a release é o workflow, a partir da tag.
publish: null
# O provedor NÃO é para publicar daqui (o `dist` roda com --publish never;
# quem cria a release é o workflow). Ele existe por dois efeitos colaterais
# obrigatórios: gerar o `latest.yml`, que é o índice que o auto-updater lê, e
# embutir o `app-update.yml` no pacote, que é como o app sabe onde procurar.
publish:
provider: github
owner: blackfoxsoftware
repo: wslc-ui
Loading