Aplicação React Native/Expo para navegar repositórios do GitHub e GitLab, com troca de provedor em tempo de execução.
npm install
npx expo prebuild # gera ios/ e android/ (obrigatório — ver Módulos nativos abaixo)
npm run ios # ou: npm run androidnpm start (Expo Go) funciona para tudo, exceto renderização de README, que exige o dev client buildado via run:ios/run:android (ver Módulos nativos).
Este projeto segue Clean Architecture com princípios de Hexagonal (Ports & Adapters).
- Domain — Entidades, value objects, ports (interfaces de repositório) e use cases em TypeScript puro
- Infrastructure — Adapters concretos (API do GitHub/GitLab, mappers, hooks de React Query)
- Presentation — Telas, componentes, hooks, navegação
- Composition Root — Fiação de injeção de dependência
- A camada de domínio tem ZERO dependências externas (roda em Node.js puro — sem React, sem Axios, sem React Query)
- A UI nunca conhece as implementações de GitHub/GitLab — as telas chamam as interfaces
ProviderPort/IssuePort, nuncaaxiosou os adapters concretos diretamente - A troca de provedor acontece em um único ponto (
ProviderFactory) — semif (provider === 'github')espalhado por telas ou hooks - DTOs nunca vazam além da camada de infraestrutura — cada provedor tem seu próprio DTO + mapper traduzindo para o shape de domínio compartilhado
Repository/Issue
Os próprios critérios de avaliação do teste dão peso Alta para "Arquitetura & Desacoplamento" e "Múltiplas Fontes de Dados", e ambos tratam explicitamente de inversão de dependência, não de escolher um layout de pastas específico — o próprio PDF diz isso ("Não existe uma estrutura de pastas obrigatória"). Clean Architecture foi escolhida porque entrega inversão de dependência "de graça": o domínio define RepositoryPort/IssuePort como contratos, e os adapters GitHub/GitLab os implementam — o domínio nunca importa Axios, e uma tela nunca importa uma classe de adapter concreta. O custo é mais arquivos/indireção do que uma pasta services/ plana exigiria; para um app deste tamanho isso é um trade-off real, justificado aqui porque "troca de fonte sem mudar a UI" é o requisito de maior peso no teste.
Clean Architecture define as camadas e a direção das dependências; Hexagonal é o padrão concreto usado pra materializar isso no código — cada ponto de contato do domínio com o mundo externo (GitHub, GitLab, storage) vira um port (interface definida pelo domínio: RepositoryPort, IssuePort) implementado por um adapter na camada de infraestrutura. A escolha resolve diretamente o requisito de maior peso do teste, "Múltiplas Fontes de Dados":
- O domínio declara o contrato (
search(query): Promise<Repository[]>) sem saber se quem implementa é GitHub ou GitLab — a dependência aponta de fora pra dentro, nunca o contrário - Adicionar uma terceira fonte (ex.: Bitbucket) significa escrever um novo adapter que implementa os ports existentes — zero mudança no domínio, nos use cases ou nas telas
- Os adapters ficam isolados o bastante pra testar o domínio inteiro com mocks simples dos ports, sem subir um cliente HTTP real ou depender do formato de resposta de nenhuma API
O custo é a indireção: toda operação passa por uma interface antes de chegar na implementação, o que exige mais saltos entre arquivos pra seguir o fluxo de uma chamada. Pra um app com duas fontes de dados que precisam ser intercambiáveis em runtime, esse é exatamente o problema que Hexagonal resolve — o trade-off compensa aqui, mesmo sendo overkill pra um CRUD de fonte única.
- Expo SDK 56, React Native 0.85, TypeScript 6 (modo strict, sem
any) - NativeWind (Tailwind para React Native) — design tokens via CSS variables, dark/light mode manual + do sistema
- React Navigation 7
- TanStack Query (React Query) — cache, infinite queries, pull-to-refresh
- Zustand — estado apenas de UI:
activeProvidere a preferência de tema (light/dark/system, persistida — ver abaixo). Query de busca, filtro de issue etc. são estado local do componente, não estado global @react-native-async-storage/async-storage— persiste a preferência de tema entre reinícios do app (via middlewarepersistdo zustand)- Axios — cliente HTTP com interceptors de request/response
@gorhom/bottom-sheet— seletor de provedor, acessível tanto pela aba Sources quanto por um atalho na tela de Buscareact-native-toast-message— notificações de erro/aviso não bloqueantes (ver Padrões de UX)react-native-enriched-markdown— renderização nativa CommonMark + GFM para READMEs de repositório (exige New Architecture + dev client, ver Módulos nativos)- Jest + React Native Testing Library
- Reactotron — inspetor de rede/estado, apenas em dev (ver Debug)
src/
├── domain/ # Entidades, value objects, ports, use cases — zero imports externos
├── infrastructure/ # Adapters GitHub/GitLab, DTOs, mappers, cliente HTTP, hooks de React Query
├── presentation/ # Telas, componentes do design system, navegação
└── shared/ # Design tokens, tema
O app suporta troca em tempo real entre GitHub e GitLab com zero mudanças na UI:
- O usuário escolhe um provedor na aba Sources, ou abre o mesmo seletor como bottom sheet a partir da tela de Busca (
ProviderSwitchSheet) — a troca não fica limitada ao startup do app nem a uma única tela - O Zustand atualiza
activeProvider; um toast confirma a troca - Cada tela chama
ProviderFactory.create(activeProvider)— o único ponto onde um tipo de provedor resolve para adapters concretos - Os hooks de React Query recebem as novas instâncias de
RepositoryPort/IssuePort. Toda query key inclui o provedor ativo (['repositories', provider, 'search', query]etc.), então trocar de provedor invalida o cache do provedor anterior e força um refetch real, em vez de reaproveitar silenciosamente resultados do GitHub em cache sob a aba do GitLab - GitHub e GitLab retornam JSON estruturalmente diferente (nomes de campo diferentes, paginação diferente — array + headers no GitLab vs. corpo
{items, total_count}no GitHub) — isso é totalmente absorvido pelo DTO + mapper de cada provedor. O shape de domínioRepository/Issueé idêntico independentemente da fonte.
- Loading:
Spinnerno primeiro carregamento,Skeletondisponível no design system (ainda não conectado a um estado de loading real — ver abaixo) - Estado vazio:
EmptyStatequando uma busca/lista de issues resolve com zero itens - Erros — bloqueante vs. não bloqueante:
ErrorState(tela cheia, com retry) só aparece quando a query não tem nenhum dado em cache pra cair de volta. Uma falha de refetch/pull-to-refresh com resultados já na tela não os apaga — vira um toast em vez disso, então o usuário mantém o que já tinha enquanto uma nova tentativa em background pode ter sucesso silenciosamente - Pull-to-refresh e infinite scroll nas telas de Busca e Issues
- Busca com debounce de 300ms, fechar teclado ao tocar fora,
keyboardShouldPersistTaps="handled"para que o primeiro toque em um resultado com o teclado aberto já registre
Nenhuma das duas APIs exige autenticação para recursos públicos. Um token só aumenta o limite de requisições (GitHub: 60/hora anônimo vs. 5000 autenticado).
cp .env.example .env
# depois preencha EXPO_PUBLIC_GITHUB_TOKEN / EXPO_PUBLIC_GITLAB_TOKEN se tiver.env está no gitignore — nunca commitar tokens reais.
Ver Instalação e execução no topo para o setup completo.
npm test # roda todos os testes
npm test -- --watch # modo watch
npm test -- path/to/test.spec.ts # arquivo únicoUse cases de domínio, mappers, componentes do design system e as quatro telas (SourceSelector, RepositorySearch, RepositoryDetails, Issues — hooks de React Query/Zustand mockados, navegação verificada via um stub de navigation.navigate) estão cobertos. Testes de snapshot foram deliberadamente evitados (frágeis, baixo sinal).
Fazer um teste de tela sequer rodar exigiu corrigir a infraestrutura de Jest quebrada, não só escrever o teste:
react-native-enriched-markdown(nativo puro, ESM) e@gorhom/bottom-sheet(que arrastareact-native-reanimated/react-native-worklets, cuja parte nativa nunca inicializa sob Jest nessa combinação de versões) são puxados transitivamente por qualquer tela que importe o barrel de componentes compartilhados — nenhum dos dois tinha binding de teste, e agora resolvem para<View>/<Text>simples emsrc/test-mocks/viamoduleNameMapper.- O
transformIgnorePatternsdojest.config.jstinha um bug de regex —react-nativeno allowlist exigia uma/final literal, então só casavanode_modules/react-native/exato e silenciosamente falhava em transformarreact-native-gesture-handler,react-native-reanimatedetc. (pacotes que o padrão default do jest-expo já cobria corretamente antes desse projeto sobrescrever a config). jest.setup.jsexistia mas nunca tinha sido ligado aosetupFilesdojest.config.js— um arquivo morto. Agora carregareact-native-gesture-handler/jestSetupe o mock oficial dereact-native-safe-area-context/jest/mock.- Corrigir isso expôs uma regressão no
tsc:jest.setup.jsnão estava noexcludedotsconfig.json(sójest.config.jsestava), então ao exigir o mock de safe-area-context otscseguia para o.tsxfonte real desse pacote (customConditions: ["react-native"]resolve pra lá, não pro.d.tscompilado) e quebrava emprocess.env.NODE_ENV— propriedade que o tipo globalprocessestreitado doenv.d.tsnão declara. Adicionadojest.setup.jsaoexcludedotsconfig.json.
@testing-library/react-native@14 exige o pacote test-renderer como peer dependency real, que nunca tinha sido instalado — o setup original redirecionava pro react-test-renderer legado via moduleNameMapper, com um mock manual escrito à mão aparentemente pra suprir a lacuna. Só que moduleNameMapper tem prioridade sobre mocks manuais em __mocks__, então esse mock nunca foi de fato usado, e o redirect simples não implementa dispatch de evento real. Resultado: fireEvent.press/fireEvent.changeText rodavam sem erro e silenciosamente não faziam nada — o que mascarava dois testes que chamavam .props.onPress()/.props.onChangeText() diretamente em vez de disparar um evento real, passando pelo motivo errado. Instalado o test-renderer de verdade, removidos o mapper/mock, e corrigidos os dois testes para usar fireEvent contra o host node real.
npm run lint # ESLint + Prettier check
npm run format # ESLint --fix + Prettier --write
npx tsc --noEmit # checagem de tiposReactotron está conectado apenas em builds de desenvolvimento (src/infrastructure/debug/reactotron.ts, protegido por __DEV__, importado uma vez em App.tsx). Abra o app desktop do Reactotron antes de iniciar o Metro pra ver cada request/response HTTP ao vivo — o status/corpo exato que o app recebeu, muito mais útil pra diagnosticar problemas de API do que ler a .message de um erro capturado na tela.
react-native-enriched-markdown (renderização de README) exige a New Architecture do React Native e traz código nativo real — não roda no Expo Go. Este projeto já tem newArchEnabled=true (Android) e as pastas ios//android/ já pré-buildadas, então:
npx expo prebuild # rode de novo após puxar mudanças que mexam em deps nativas
npx expo run:ios # ou: npx expo run:androidSó npm start (Expo Go) vai falhar ao renderizar READMEs — buildar um dev client via run:ios/run:android pelo menos uma vez.
| Decisão | Por quê |
|---|---|
| Split Zustand + React Query | Zustand pra estado apenas de UI (activeProvider, preferência de tema persistida), React Query pra estado de servidor — sem sobreposição |
Zustand persist + AsyncStorage pro tema |
O colorScheme do próprio NativeWind não tem persistência; um componente ThemeSync aplica a preferência persistida ao NativeWind no mount, pra sobreviver a um reinício do app em vez de resetar pro sistema |
| Factory Pattern pros provedores | Um único ponto de troca, sem if/else espalhado pelas telas |
| Branded types pros IDs de repositório/issue | Segurança de tipos evita misturar acidentalmente um ID de uma entidade com outra |
| Renderer de markdown nativo em vez de um puro-JS | react-native-enriched-markdown entrega CommonMark + GFM real (tabelas, task lists) nativamente; custa compatibilidade com Expo Go (exige dev client) — vale a pena já que READMEs usam ambos com frequência |
| Sem testes de snapshot | Frágeis, baixo valor, difíceis de manter — assertions sobre comportamento em vez disso |
| NativeWind + CSS variables | Design tokens como CSS custom properties (rgb(var(--x) / <alpha-value>)) pra que light/dark seja uma troca de seletor, não lógica por componente |
| Sem login OAuth manual | Não exigido pelo briefing do teste (só um token opcional em .env é) — um gate de login bloquearia os avaliadores de chegar nas telas de fato avaliadas, sem nenhum ganho de requisito |
| Sem integração offline/NetInfo | Exigiria uma nova dependência nativa que eu não conseguiria verificar em um dispositivo real neste ambiente — deixado de fora em vez de meio-feito |
| Links de README restritos a http(s) | Markdown de README é conteúdo não confiável, vindo de qualquer dono de repositório — um link não é garantidamente http(s) (javascript:, custom app schemes, intent: no Android). onLinkPress rejeita qualquer coisa fora de um allowlist http(s) e checa canOpenURL antes de abrir |
Este projeto foi construído com assistência intensiva de IA (Claude), em quatro fases:
Fase 1 — implementação inicial. Um prompt genérico e extenso (não derivado do PDF real deste teste) foi usado para gerar a base do projeto: estrutura de camadas, design tokens, componentes do design system, entidades/use cases de domínio, adapters GitHub/GitLab, navegação e testes. Esse prompt incluía requisitos que não estavam no PDF real — notavelmente um fluxo de autenticação OAuth completo — que foi implementado como stub incompleto (throw new Error('OAuth flow not implemented yet')) mas ainda assim guardava toda a navegação atrás de uma tela de login.
Fase 2 — auditoria e correção contra o PDF real. Depois de anexar o PDF do teste técnico, foi feita uma auditoria linha a linha comparando o que existia contra o que o PDF de fato pedia. Isso revelou vários problemas reais, todos corrigidos nesta fase:
- Bug crítico de cores: os tokens de cor no
tailwind.config.jsestavam definidos como objetos aninhados{light, dark}, que o Tailwind não converte nas classes planas (bg-primary,text-text-secondary) usadas em 15+ arquivos — o sistema de cores inteiro não funcionava (sem estilo, sem dark mode). Corrigido reescrevendo os tokens como variáveis CSS. - Bug de paginação do GitLab: o adapter assumia um body
{data: [...], total: ...}; a API real do GitLab retorna um array puro com paginação nos headers (X-Total). Isso quebraria a busca no GitLab em produção — nunca foi de fato testado contra a API real. - Interceptor de retry morto: aguardava um delay e rejeitava de qualquer forma, nunca reenviava a requisição — "retry" não fazia nada. Corrigido para reemitir a requisição via a mesma instância do Axios.
- Ordem errada de interceptors: o interceptor de erro (que transforma o erro em mensagem amigável) rodava antes do de retry, então o retry nunca via o
AxiosErrorreal — corrigido invertendo a ordem de registro. - Violação de arquitetura: a tela de login manipulava o estado de autenticação diretamente, sem passar pelo
LoginUseCase/AuthPort— violando a própria regra "UI consome apenas casos de uso" que o teste pede. - Gap funcional: os resultados de busca não eram clicáveis — não existia navegação de Busca → Detalhes na UI, apesar da tela de detalhes existir.
- Campo
watchersausente na entidadeRepository(exigido explicitamente pelo PDF §4.3), texto de UI inteiramente em inglês (o PDF é em português), ausência de ícones nas abas, paleta de cores genérica em vez de referenciar a identidade visual do próprio PDF, entre outros itens menores. - Decisão de escopo: removida completamente a tela de login/OAuth e toda a camada de domínio de autenticação (
AuthPort,LoginUseCase,LogoutUseCase, adapters de auth,StoragePort/SecureStore) — nada disso é exigido pelo PDF, e o gate de login impedia o app de abrir direto na tela que o PDF define como inicial (Source Selector).
Fase 3 — polimento e correção de bugs adicionais, encontrados ao adicionar bottom sheet (troca de fonte), toast (avisos/erros) e ao revisar boas práticas de React/testes:
- Cache não isolado por fonte: nenhuma query key incluía o provider ativo (
['repositories', 'search', query]era igual pra GitHub e GitLab) — trocar de fonte reaproveitava o cache da fonte anterior em vez de buscar de novo. Corrigido incluindo o provider em toda key. - Violações de rules-of-hooks:
SkeletoneQueryProviderliamuseRef(...).currentdurante o render (proibido pelas regras de hooks do React — só devia ler/escrever refs em efeitos/handlers); trocado poruseState(() => valor)[0](lazy init seguro).Skeletontambém nunca parava a animação em loop no unmount. ActivityIndicatorcom tamanho ignorado: osizenumérico do RNActivityIndicatoré amplamente ignorado pelas views nativas — os 3 tamanhos doSpinner(sm/md/lg) renderizavam visualmente idênticos. Corrigido comtransform: scale.fireEventsilenciosamente quebrado: ver seção Testes — bug de configuração de teste que faziafireEvent.press/fireEvent.changeTextrodarem sem erro e sem disparar nada, mascarando dois testes que na prática não testavam interação real.- Interceptor de retry morto e ordem errada de interceptors, paginação do GitLab assumindo shape errado de resposta, campo
watchersausente: ver commitsfix: correct GitHub/GitLab API integration. - Markdown do README: trocado o parser manual (só títulos/negrito/listas) por
react-native-enriched-markdown— renderização nativa CommonMark + GFM (tabelas, task lists), sem WebView. Exige New Architecture (já habilitada no projeto) e não roda no Expo Go — precisa deexpo prebuild+ dev client.
Fase 4 — infraestrutura de teste de tela. O item "testes de tela" ficou pendente por um bom tempo em O que eu faria diferente porque não era só escrever o teste — a configuração de Jest do projeto não sustentava renderizar uma tela real:
react-native-enriched-markdown(nativo puro, ESM) e@gorhom/bottom-sheet(que arrastareact-native-reanimated/react-native-worklets, cuja parte nativa nunca inicializa sob Jest nessa combinação de versões) são puxados transitivamente por qualquer tela que importe o barrel de componentes — nenhum dos dois tem binding utilizável em teste. Resolvido com stubs próprios (src/test-mocks/) viamoduleNameMapper.transformIgnorePatternsdojest.config.jstinha um bug de regex: exigia/logo após cada nome de pacote no allowlist, então só casavanode_modules/react-native/exato — silenciosamente parava de transformarreact-native-gesture-handler,react-native-reanimatedetc. (pacotes que o padrão default dojest-expojá cobria corretamente antes desse projeto sobrescrever a config).jest.setup.jsexistia mas nunca tinha sido ligado aosetupFilesdojest.config.js— arquivo morto. Corrigido, agora carregareact-native-gesture-handler/jestSetupe o mock oficial dereact-native-safe-area-context.- Isso expôs uma regressão no
tsc:jest.setup.jsnão estava noexcludedotsconfig.json(sójest.config.jsestava), então ao carregar o mock de safe-area-context otscseguia para o.tsxfonte real do pacote (customConditions: ["react-native"]resolve para lá, não para o.d.tscompilado) e quebrava emprocess.env.NODE_ENV— propriedade que oenv.d.tsdo projeto (que estreita o tipo global deprocess) não declara. Corrigido adicionandojest.setup.jsaoexclude.
Com a infra corrigida, as 3 telas restantes (RepositoryDetails, Issues, SourceSelector) seguiram o mesmo padrão sem atrito adicional — nenhum novo bug de configuração apareceu.
Ver detalhes completos em Testes.
O que foi revisado/rejeitado da IA: todo o código gerado na Fase 1 foi lido, testado (tsc, eslint, jest) e comparado linha a linha contra o PDF real antes de qualquer correção ser aplicada — nenhuma mudança foi aceita sem entender por que o comportamento anterior estava errado. Boa parte dos bugs das Fases 2 e 3 só apareceram porque o código da Fase 1 nunca tinha sido de fato exercitado contra as APIs reais nem testado com interações reais.
- OAuth real para GitLab é factível sem backend (Authorization Code + PKCE, aplicação pública sem client secret); para GitHub exigiria um backend mínimo só para trocar
codeportoken(OAuth Apps clássicos do GitHub não suportam PKCE puro) — não implementado por não ser exigido pelo PDF, mas seria o próximo passo natural caso autenticação real fosse necessária. - Suporte offline (§7 do PDF) com
@react-native-community/netinfo— mostrar dados em cache com indicador de "sem conexão" e nova tentativa automática ao reconectar.
MIT